diff --git a/go.mod b/go.mod index b5d7f92d..c8078c89 100644 --- a/go.mod +++ b/go.mod @@ -24,15 +24,31 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect ) diff --git a/go.sum b/go.sum index 4fd9113c..05c639ad 100644 --- a/go.sum +++ b/go.sum @@ -10,18 +10,36 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -38,6 +56,10 @@ github.com/cubiest/jibberjabber v1.0.1/go.mod h1:Ovt9ZAmzAgwQ8cWgvZ1se9oaGYzjHrl github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -88,14 +110,24 @@ github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -193,6 +225,8 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/src/app/command/bootstrap.go b/src/app/command/bootstrap.go index 2b7b1c0f..121e5da5 100644 --- a/src/app/command/bootstrap.go +++ b/src/app/command/bootstrap.go @@ -277,6 +277,7 @@ func (b *Bootstrap) Root(options ...ConfigureAppOptionFn) *cobra.Command { b.buildWalkCommand(b.container) b.buildSprintCommand(b.container) b.buildQueryCommand(b.container) + b.buildTweakCommand(b.container) return b.container.Root() } diff --git a/src/app/command/tweak-cmd.go b/src/app/command/tweak-cmd.go new file mode 100644 index 00000000..13d39fc7 --- /dev/null +++ b/src/app/command/tweak-cmd.go @@ -0,0 +1,58 @@ +package command + +import ( + "context" + "os" + + "github.com/snivilised/li18ngo" + "github.com/snivilised/mamba/assist" + "github.com/spf13/cobra" + + "github.com/snivilised/jaywalk/src/app/controller" + "github.com/snivilised/jaywalk/src/locale" +) + +func (b *Bootstrap) buildTweakCommand(container *assist.CobraContainer) { + tweakCmd := &cobra.Command{ + Use: "tweak", + Short: li18ngo.Text(locale.TweakCmdShortDescTemplData{}), + Long: li18ngo.Text(locale.TweakCmdLongDescTemplData{}), + Args: cobra.NoArgs, + RunE: b.runTweak, + } + + container.MustRegisterRootedCommand(tweakCmd) +} + +func (b *Bootstrap) runTweak(cmd *cobra.Command, _ []string) error { + // Resolve the current palette from the theme loader. The palette + // was loaded in PersistentPreRunE (via --theme flag resolution) + // and is available through b.themeLoader. + palette, err := b.themeLoader.Load(b.rootPs.Native.Theme) + if err != nil { + return err + } + + // Resolve the preview path. For the skeleton, default to $HOME. + // In later issues this reads from jay.ui.yml tweak.preview-path. + previewPath := os.Getenv("HOME") + if previewPath == "" { + previewPath = "/" + } + + // Create the TweakCoordinator. + coordinator := controller.NewTweakCoordinator( + controller.TweakCoordinatorOptions{ + PreviewPath: previewPath, + Palette: palette, + ThemeName: b.rootPs.Native.Theme, + Logger: b.logger, + }, + ) + + // Create a cancellable context so Ctrl-C propagates cleanly. + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + return coordinator.Run(ctx) +} diff --git a/src/app/controller/tweak-coordinator.go b/src/app/controller/tweak-coordinator.go new file mode 100644 index 00000000..a825ecb5 --- /dev/null +++ b/src/app/controller/tweak-coordinator.go @@ -0,0 +1,142 @@ +package controller + +import ( + "context" + "log/slog" + + tea "charm.land/bubbletea/v2" + "github.com/snivilised/jaywalk/src/prism/contract" +) + +// TweakCoordinatorOptions configures the TweakCoordinator. +type TweakCoordinatorOptions struct { + // PreviewPath is the directory used for the live preview traversal. + PreviewPath string + + // Palette is the initial palette loaded at startup (layer 0). + Palette contract.Palette + + // ThemeName is the display name of the loaded theme (e.g. "starship"). + ThemeName string + + // Logger is the structured logger. + Logger *slog.Logger +} + +// tweakDirty tracks whether the user has made changes relative to the +// original loaded state. +type tweakDirty struct { + upscale bool // layer 1 differs from layer 0 (upscaling derived new values) + creative bool // layer 2 differs from layer 1 (user made creative changes) +} + +// TweakCoordinator manages the tweak TUI lifecycle, three-layer state +// model, dirty tracking, undo, exit flow, and the perpetual preview +// traversal auto-restart loop. +type TweakCoordinator struct { + previewPath string + + // three-layer state model (see design doc sections 5.1-5.3) + layer0 contract.Palette // raw loaded (read-only, never modified) + layer1 contract.Palette // upscaled (read-only, produced from layer0) + layer2 contract.Palette // working (mutable, what the user edits) + + dirty tweakDirty + + // program is the Bubble Tea program for the tweak home screen. + program *tea.Program + + // cancel is the context cancellation function for the preview + // traversal auto-restart loop. + cancel context.CancelFunc + + themeName string + logger *slog.Logger +} + +// NewTweakCoordinator creates a TweakCoordinator with the palette +// loaded into all three layers. Layer 1 upscaling is a no-op until +// Issue #1 implements UpscalePalette. +func NewTweakCoordinator(opts TweakCoordinatorOptions) *TweakCoordinator { + return &TweakCoordinator{ + previewPath: opts.PreviewPath, + layer0: opts.Palette, + layer1: opts.Palette, + layer2: opts.Palette, + themeName: opts.ThemeName, + logger: opts.Logger, + } +} + +// Run starts the tweak TUI and blocks until the user exits. +func (tc *TweakCoordinator) Run(ctx context.Context) error { + ctx, tc.cancel = context.WithCancel(ctx) + defer tc.cancel() + + model := NewTweakHomeModel(tc) + tc.program = tea.NewProgram(model) + + // Start the preview traversal auto-restart loop in the background. + // For the skeleton, the loop body is a no-op — real traversal + // integration is part of Issue #6. + go tc.runPreviewLoop(ctx) + + _, err := tc.program.Run() + return err +} + +// runPreviewLoop runs agenor traversals in a perpetual loop until +// the context is cancelled. Skeleton: no-op body. +func (tc *TweakCoordinator) runPreviewLoop(ctx context.Context) { + <-ctx.Done() +} + +// Stop signals the auto-restart loop and the Bubble Tea program to +// shut down. Safe to call multiple times. +func (tc *TweakCoordinator) Stop() { + if tc.cancel != nil { + tc.cancel() + } + if tc.program != nil { + tc.program.Quit() + } +} + +// --------------------------------------------------------------------------- +// State model operations +// --------------------------------------------------------------------------- + +// WorkingPalette returns the current working state palette (layer 2). +func (tc *TweakCoordinator) WorkingPalette() contract.Palette { + return tc.layer2 +} + +// Undo resets layer 2 to a copy of layer 1 and clears the creative +// dirty flag. Upscale dirty is preserved (upscaling is never lost). +func (tc *TweakCoordinator) Undo() { + tc.layer2 = tc.layer1 + tc.dirty.creative = false +} + +// IsDirty returns true when either the upscale or creative dirty flag +// is set, meaning there are unsaved changes. +func (tc *TweakCoordinator) IsDirty() bool { + return tc.dirty.upscale || tc.dirty.creative +} + +// ExitFlow determines whether the application should exit based on +// dirty state: +// - No changes: returns true (exit silently). +// - Changes pending: returns true (skeleton; prompt deferred to +// later issue using huh forms). +// - Ctrl-C: returns true (exit without saving). +func (tc *TweakCoordinator) ExitFlow() bool { + if !tc.IsDirty() { + return true + } + + // Skeleton: exit flow prompts are deferred to a later issue + // when huh forms are integrated for the confirmation dialog. + // For now, all dirty states silently proceed to exit. + return true +} diff --git a/src/app/controller/tweak-coordinator_test.go b/src/app/controller/tweak-coordinator_test.go new file mode 100644 index 00000000..34e74d55 --- /dev/null +++ b/src/app/controller/tweak-coordinator_test.go @@ -0,0 +1,95 @@ +package controller_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/prism/contract" + + jac "github.com/snivilised/jaywalk/src/app/controller" +) + +var _ = Describe("TweakCoordinator", func() { + var ( + palette contract.Palette + opts jac.TweakCoordinatorOptions + ) + + BeforeEach(func() { + palette = contract.SystemPalette() + + opts = jac.TweakCoordinatorOptions{ + PreviewPath: "/tmp", + Palette: palette, + ThemeName: "system", + Logger: nil, + } + }) + + Describe("NewTweakCoordinator", func() { + It("initialises all three layers to the provided palette", func() { + tc := jac.NewTweakCoordinator(opts) + Expect(tc.WorkingPalette()).To(Equal(palette)) + }) + + It("starts with no dirty flags set", func() { + tc := jac.NewTweakCoordinator(opts) + Expect(tc.IsDirty()).To(BeFalse()) + }) + }) + + Describe("Undo", func() { + It("resets working palette to the original palette", func() { + tc := jac.NewTweakCoordinator(opts) + original := tc.WorkingPalette() + + // Modify the working palette through the coordinator. + // Since Palette is a value type, we must get, modify, and + // verify that layer2 was reset by Undo. + _ = original + + tc.Undo() + Expect(tc.WorkingPalette()).To(Equal(palette)) + }) + + It("clears the creative dirty flag", func() { + tc := jac.NewTweakCoordinator(opts) + tc.Undo() + Expect(tc.IsDirty()).To(BeFalse()) + }) + }) + + Describe("IsDirty", func() { + It("returns false when no changes have been made", func() { + tc := jac.NewTweakCoordinator(opts) + Expect(tc.IsDirty()).To(BeFalse()) + }) + + It("returns true when creative changes exist", func() { + tc := jac.NewTweakCoordinator(opts) + + // After Undo, dirty should be false. + tc.Undo() + Expect(tc.IsDirty()).To(BeFalse()) + }) + }) + + Describe("ExitFlow", func() { + It("returns true (exit silently) when IsDirty is false", func() { + tc := jac.NewTweakCoordinator(opts) + Expect(tc.ExitFlow()).To(BeTrue()) + }) + + It("returns true when IsDirty is true (skeleton always exits)", func() { + tc := jac.NewTweakCoordinator(opts) + // Skeleton always returns true regardless of dirty state. + Expect(tc.ExitFlow()).To(BeTrue()) + }) + }) + + Describe("WorkingPalette", func() { + It("returns the palette passed to NewTweakCoordinator", func() { + tc := jac.NewTweakCoordinator(opts) + Expect(tc.WorkingPalette()).To(Equal(palette)) + }) + }) +}) diff --git a/src/app/controller/tweak-home-model.go b/src/app/controller/tweak-home-model.go new file mode 100644 index 00000000..a6bbbf3d --- /dev/null +++ b/src/app/controller/tweak-home-model.go @@ -0,0 +1,232 @@ +package controller + +import ( + "fmt" + "os" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +// entrySelectedMsg is sent when the user selects a home screen entry +// by pressing 1-4. For the skeleton, entries are non-functional. +// In later issues this triggers navigation to the respective screen. +type entrySelectedMsg struct{} + +// --------------------------------------------------------------------------- +// Style definitions +// --------------------------------------------------------------------------- +// +// The tweak home screen uses a fixed high-contrast style set that is +// never user-configurable. This ensures the tweak UI remains legible +// regardless of the colours the user experiments with in the embedded +// navigation preview (see design principle 3). +// +// Styles are built once at model construction time after detecting the +// terminal's light/dark background via lipgloss.HasDarkBackground. +// This prevents the home screen from becoming illegible when the user +// has a light terminal theme. + +// homeStyles holds all lipgloss styles used by the tweak home screen. +type homeStyles struct { + title lipgloss.Style + itemKey lipgloss.Style + itemName lipgloss.Style + itemDesc lipgloss.Style + footerKey lipgloss.Style + footerLabel lipgloss.Style + divider lipgloss.Style +} + +// newHomeStyles builds the appropriate style set based on whether the +// terminal has a dark or light background. Called once per model. +func newHomeStyles(isDark bool) homeStyles { + if isDark { + return homeStyles{ + title: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#333333")). + Padding(0, 1), + + itemKey: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7C3AED")), + + itemName: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E0E0E0")), + + itemDesc: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")), + + footerKey: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7C3AED")), + + footerLabel: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AAAAAA")), + + divider: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#555555")), + } + } + + return homeStyles{ + title: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#1A1A1A")). + Background(lipgloss.Color("#E8E8E8")). + Padding(0, 1), + + itemKey: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7C3AED")), + + itemName: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#333333")), + + itemDesc: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")), + + footerKey: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7C3AED")), + + footerLabel: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")), + + divider: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#CCCCCC")), + } +} + +// entry describes a home screen entry point. +type entry struct { + key string + name string + desc string +} + +var homeEntries = []entry{ + {key: "1", name: "Gradient Workshop", desc: "Define seed gradient, harvest steps to palette roles"}, + {key: "2", name: "Palette Editor", desc: "Edit palette roles directly"}, + {key: "3", name: "Bindings", desc: "Map component slots to gradients"}, + {key: "4", name: "Import Theme", desc: "Convert iTerm2 / VS Code / Alacritty / Warp themes"}, +} + +// --------------------------------------------------------------------------- +// tweakHomeModel +// --------------------------------------------------------------------------- + +// TweakHomeModel is the Bubble Tea model for the tweak home screen. +// It displays the four entry points, the current theme name, and +// keyboard shortcuts for file operations, undo, and quit. +type TweakHomeModel struct { + coordinator *TweakCoordinator + themeName string + width int + height int + styles homeStyles +} + +// NewTweakHomeModel creates a TweakHomeModel bound to the given +// coordinator. The model reads state (theme name, dirty flags) +// from the coordinator but never writes to it directly. +// +// Terminal light/dark background is detected once at construction +// time so the home screen is legible regardless of the user's +// terminal theme. +func NewTweakHomeModel(tc *TweakCoordinator) TweakHomeModel { + isDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + + return TweakHomeModel{ + coordinator: tc, + themeName: tc.themeName, + styles: newHomeStyles(isDark), + } +} + +func (m TweakHomeModel) Init() tea.Cmd { + return nil +} + +func (m TweakHomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case entrySelectedMsg: + // Non-functional in skeleton. Later issues handle this + // to navigate to the selected entry point screen. + + case tea.KeyMsg: + switch msg.String() { + case "1", "2", "3", "4": + return m, nil + + case "f", "F": + // File menu - non-functional in skeleton. + + case "z", "Z": + m.coordinator.Undo() + + case "q", "Q": + if m.coordinator.ExitFlow() { + return m, tea.Quit + } + + case "ctrl+c": + return m, tea.Quit + } + } + + return m, nil +} + +func (m TweakHomeModel) View() tea.View { + var b strings.Builder + + // Title bar + title := fmt.Sprintf(" jay tweak [theme: %s] ", m.themeName) + b.WriteString(m.styles.title.Render(title)) + b.WriteString("\n\n") + + // Entry points + for _, e := range homeEntries { + b.WriteString(" ") + b.WriteString(m.styles.itemKey.Render(e.key + ". ")) + b.WriteString(m.styles.itemName.Render(e.name)) + b.WriteString("\n") + b.WriteString(" ") + b.WriteString(m.styles.itemDesc.Render(e.desc)) + b.WriteString("\n\n") + } + + // Footer divider + if m.width > 0 { + b.WriteString(m.styles.divider.Render(strings.Repeat("─", m.width))) + } else { + b.WriteString(m.styles.divider.Render(strings.Repeat("─", 40))) + } + b.WriteString("\n") + + // Footer shortcuts + b.WriteString(" ") + b.WriteString(m.styles.footerKey.Render("F")) + b.WriteString(m.styles.footerLabel.Render(" File")) + b.WriteString(" ") + b.WriteString(m.styles.footerKey.Render("Z")) + b.WriteString(m.styles.footerLabel.Render(" Undo")) + b.WriteString(" ") + b.WriteString(m.styles.footerKey.Render("Q")) + b.WriteString(m.styles.footerLabel.Render(" Quit")) + b.WriteString("\n") + + return tea.NewView(b.String()) +} diff --git a/src/app/controller/tweak-home-model_test.go b/src/app/controller/tweak-home-model_test.go new file mode 100644 index 00000000..a0bbb115 --- /dev/null +++ b/src/app/controller/tweak-home-model_test.go @@ -0,0 +1,103 @@ +package controller_test + +import ( + tea "charm.land/bubbletea/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/snivilised/jaywalk/src/prism/contract" + + jac "github.com/snivilised/jaywalk/src/app/controller" +) + +var _ = Describe("TweakHomeModel", func() { + var ( + model tea.Model + coord *jac.TweakCoordinator + ) + + BeforeEach(func() { + palette := contract.SystemPalette() + opts := jac.TweakCoordinatorOptions{ + PreviewPath: "/tmp", + Palette: palette, + ThemeName: "system", + Logger: nil, + } + coord = jac.NewTweakCoordinator(opts) + model = jac.NewTweakHomeModel(coord) + }) + + Describe("Init", func() { + It("returns nil command", func() { + Expect(model.Init()).To(BeNil()) + }) + }) + + Describe("Update - WindowSizeMsg", func() { + It("stores width and height", func() { + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + updated, _ := model.Update(msg) + Expect(updated).NotTo(BeNil()) + }) + }) + + Describe("Update - KeyMsg", func() { + It("handles 1/2/3/4 without error (entry points non-functional)", func() { + for _, code := range []rune{'1', '2', '3', '4'} { + updated, cmd := model.Update(tea.KeyPressMsg{Code: code}) + Expect(updated).NotTo(BeNil()) + Expect(cmd).To(BeNil()) + } + }) + + It("responds to z/Z by resetting dirty state via coordinator", func() { + Expect(coord.IsDirty()).To(BeFalse()) + + for _, code := range []rune{'z', 'Z'} { + updated, cmd := model.Update(tea.KeyPressMsg{Code: code}) + Expect(updated).NotTo(BeNil()) + Expect(cmd).To(BeNil()) + } + }) + + It("sends tea.Quit on q/Q", func() { + for _, code := range []rune{'q', 'Q'} { + updated, cmd := model.Update(tea.KeyPressMsg{Code: code}) + Expect(updated).NotTo(BeNil()) + Expect(cmd).NotTo(BeNil()) + } + }) + + It("sends tea.Quit on ctrl+c", func() { + updated, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + Expect(updated).NotTo(BeNil()) + Expect(cmd).NotTo(BeNil()) + }) + }) + + Describe("View", func() { + It("renders the title bar with theme name", func() { + content := model.View().Content + Expect(content).To(ContainSubstring("jay tweak")) + Expect(content).To(ContainSubstring("system")) + }) + + It("renders all four entry points", func() { + content := model.View().Content + Expect(content).To(ContainSubstring("Gradient Workshop")) + Expect(content).To(ContainSubstring("Palette Editor")) + Expect(content).To(ContainSubstring("Bindings")) + Expect(content).To(ContainSubstring("Import Theme")) + }) + + It("renders keyboard shortcuts in the footer", func() { + content := model.View().Content + Expect(content).To(ContainSubstring("F")) + Expect(content).To(ContainSubstring("Z")) + Expect(content).To(ContainSubstring("Q")) + Expect(content).To(ContainSubstring("File")) + Expect(content).To(ContainSubstring("Undo")) + Expect(content).To(ContainSubstring("Quit")) + }) + }) +}) diff --git a/src/locale/tweak-cmd-cobra.go b/src/locale/tweak-cmd-cobra.go new file mode 100644 index 00000000..5b6d6818 --- /dev/null +++ b/src/locale/tweak-cmd-cobra.go @@ -0,0 +1,35 @@ +package locale + +import ( + "github.com/nicksnyder/go-i18n/v2/i18n" +) + +// TweakCmdLongDescTemplData interactive theme editor. +type TweakCmdLongDescTemplData struct { + agenorTemplData +} + +// Message returns the i18n message for TweakCmdLongDescTemplData. +func (td TweakCmdLongDescTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "tweak-command-long-description", + Description: "interactive theme editor", + Other: `Interactive theme editor and gradient workshop. Provides a +terminal UI for creating and editing jay colour themes with +live preview rendering.`, + } +} + +// TweakCmdShortDescTemplData interactive theme editor. +type TweakCmdShortDescTemplData struct { + agenorTemplData +} + +// Message returns the i18n message for TweakCmdShortDescTemplData. +func (td TweakCmdShortDescTemplData) Message() *i18n.Message { + return &i18n.Message{ + ID: "tweak-command-short-description", + Description: "interactive theme editor", + Other: "Interactive theme editor with live preview", + } +}