From a21be1d78620ca81c153b22efad7b9b25a8d43b2 Mon Sep 17 00:00:00 2001 From: zaidejjo Date: Fri, 16 Oct 2026 20:40:10 +0300 Subject: [PATCH] feat(tui): P5 light theme, mouse wheel, worker pool, goldens, lint - theme engine: Mocha/Latte token swap + styles.Refresh, auto-detect, L toggle persisted to tui.theme - mouse: --mouse flag, wheel scrolls any focused list - perf: 4-worker dashboard status pool preserves order - tests: ANSI-stripped goldens for dashboard/notes/actions - ci: width-law lint forbids slicing rendered lines --- .github/workflows/ci.yml | 3 + internal/cli/tui.go | 5 +- pkg/tui/app.go | 178 +++++++++++++++++++++-- pkg/tui/styles/colors.go | 96 ++++++++++--- pkg/tui/styles/theme.go | 180 +++++++++++++++--------- pkg/tui/views/golden_test.go | 66 +++++++++ pkg/tui/views/help.go | 7 + pkg/tui/views/palette.go | 28 ++-- pkg/tui/views/testdata/actions.golden | 5 + pkg/tui/views/testdata/dashboard.golden | 6 + pkg/tui/views/testdata/notes.golden | 5 + 11 files changed, 465 insertions(+), 114 deletions(-) create mode 100644 pkg/tui/views/golden_test.go create mode 100644 pkg/tui/views/testdata/actions.golden create mode 100644 pkg/tui/views/testdata/dashboard.golden create mode 100644 pkg/tui/views/testdata/notes.golden diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 550fe25..436143e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: gofmt check (list mode — never mutates) run: test -z "$(gofmt -l .)" || (echo "gofmt found unformatted files — run 'gofmt -w .'"; exit 1) + - name: TUI width law (never slice styled strings by byte/rune) + run: test -z "$(rg -n 'line\[1:\]|runes\[1:\]' pkg/tui/ -g '!zlist.go' || true)" || (echo "found byte/rune slicing of rendered lines in pkg/tui — use components.CursorLine"; exit 1) + test: name: Test runs-on: ubuntu-latest diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 7b892d2..13459f9 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -5,15 +5,18 @@ import ( "github.com/zaidejjo/zgit/pkg/tui" ) +var tuiMouse bool + var tuiCmd = &cobra.Command{ Use: "tui", Short: "Launch the interactive terminal UI", Long: `Start the Bubble Tea-based terminal UI for an interactive Git & GitHub experience.`, RunE: func(cmd *cobra.Command, args []string) error { - return tui.Run(gitExec) + return tui.RunWithMouse(gitExec, tuiMouse) }, } func init() { + tuiCmd.Flags().BoolVar(&tuiMouse, "mouse", false, "enable mouse wheel scrolling in lists") rootCmd.AddCommand(tuiCmd) } diff --git a/pkg/tui/app.go b/pkg/tui/app.go index e53eb7f..ffea147 100644 --- a/pkg/tui/app.go +++ b/pkg/tui/app.go @@ -6,12 +6,14 @@ import ( "fmt" "os" "strings" + "sync" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/zaidejjo/zgit/pkg/core/config" gitpkg "github.com/zaidejjo/zgit/pkg/core/git" "github.com/zaidejjo/zgit/pkg/core/github" @@ -383,6 +385,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reinstall listener for next subscriber message return updated, tea.Batch(cmd, listenForMessages(m.msgs)) + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + m.scrollFocused(-3) + return m, nil + case tea.MouseButtonWheelDown: + m.scrollFocused(3) + return m, nil + } + return m, nil + case toastTickMsg: if m.toasts.Expire() { return m, func() tea.Msg { @@ -716,6 +729,9 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.reloadLocal() return m, nil + case "L": + return m, m.toggleTheme() + // Tab cycles focus: Status → Log → Branches → Status case "tab": if m.fullScreenView != shell.Panels { @@ -979,20 +995,37 @@ func (m *Model) fetchDashboard() { favs[p] = true } - entries := make([]views.DashEntry, 0, len(paths)) - for _, p := range paths { - e := views.DashEntry{Name: baseName(p), Path: p, Favorite: favs[p], Current: p == current} - if st, err := quickRepoStatus(p); err != nil { - e.Err = "unreachable" - } else { - e.Branch = st.Branch - e.Clean = st.IsClean - e.Dirty = len(st.Files) - e.Ahead = st.Ahead - e.Behind = st.Behind - } - entries = append(entries, e) + entries := make([]views.DashEntry, len(paths)) + // Bounded worker pool: N sequential git stats become ~N/4 wall time. + // Order is preserved (slot per path) since the view sorts nothing. + const workers = 4 + var wg sync.WaitGroup + slots := make(chan int, len(paths)) + for i := range paths { + slots <- i } + close(slots) + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := range slots { + p := paths[i] + e := views.DashEntry{Name: baseName(p), Path: p, Favorite: favs[p], Current: p == current} + if st, err := quickRepoStatus(p); err != nil { + e.Err = "unreachable" + } else { + e.Branch = st.Branch + e.Clean = st.IsClean + e.Dirty = len(st.Files) + e.Ahead = st.Ahead + e.Behind = st.Behind + } + entries[i] = e + } + }() + } + wg.Wait() m.msgs <- teaMsg{view: 5, data: dashEvent{entries: entries}} } @@ -2308,6 +2341,70 @@ type refsEvent struct { // toastTickMsg fires when the oldest toast may have expired. type toastTickMsg struct{} +// scrollFocused moves the focused list by delta rows (mouse wheel). +func (m *Model) scrollFocused(delta int) { + move := func(cursor *int, n int) { + *cursor += delta + if n <= 0 { + *cursor = 0 + return + } + if *cursor < 0 { + *cursor = 0 + } + if *cursor >= n { + *cursor = n - 1 + } + } + if m.fullScreenView != shell.Panels { + switch m.fullScreenView { + case shell.PRs: + move(&m.prs.Cursor, len(m.prs.Filtered())) + case shell.Issues: + move(&m.issues.Cursor, len(m.issues.Filtered())) + case shell.Dashboard: + move(&m.dash.Cursor, len(m.dash.Filtered())) + case shell.Actions: + if m.actions.JobsFor != 0 { + move(&m.actions.Cursor, len(m.actions.Jobs)) + } else { + move(&m.actions.Cursor, len(m.actions.Filtered())) + } + case shell.Notes: + move(&m.notes.Cursor, len(m.notes.Ordered())) + case shell.Worktrees: + move(&m.worktrees.Cursor, len(m.worktrees.Filtered())) + case shell.Stash: + move(&m.stash.Cursor, len(m.stash.Filtered())) + case shell.Refs: + if m.refs.Section == views.RefsTags { + move(&m.refs.Tags.Cursor, len(m.refs.Tags.Filtered())) + } else { + move(&m.refs.Remotes.Cursor, len(m.refs.Remotes.Filtered())) + } + } + return + } + switch m.focusedPanel { + case PanelStatus: + move(&m.status.Cursor, len(m.status.VisibleFiles())) + case PanelLog: + move(&m.log.Cursor, len(m.log.Commits)) + case PanelBranch: + move(&m.branches.Cursor, len(m.branches.Filtered())) + } +} + +// toggleTheme flips light/dark, persists, and announces it. +func (m *Model) toggleTheme() tea.Cmd { + if styles.Dark { + m.setTheme("light", true) + return m.pushToast("Light theme", components.ToneInfo) + } + m.setTheme("dark", true) + return m.pushToast("Dark theme", components.ToneInfo) +} + // pushToast shows a transient notification for ~4s. func (m *Model) pushToast(text string, tone components.Tone) tea.Cmd { m.toasts.Push(text, tone) @@ -2622,12 +2719,65 @@ func listenForMessages(msgs <-chan teaMsg) tea.Cmd { // Run starts the TUI application. func Run(gitExec *gitpkg.NativeExec) error { + return RunWithMouse(gitExec, false) +} + +// RunWithMouse starts the TUI, optionally reporting mouse wheel events +// for list scrolling. +func RunWithMouse(gitExec *gitpkg.NativeExec, mouse bool) error { m := NewModel(gitExec) + m.applyTheme() - p := tea.NewProgram(m, tea.WithAltScreen()) + opts := []tea.ProgramOption{tea.WithAltScreen()} + if mouse { + opts = append(opts, tea.WithMouseCellMotion()) + } + p := tea.NewProgram(m, opts...) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) return err } return nil } + +// applyTheme selects light/dark from config (tui.theme) or terminal +// background detection when unset. +func (m *Model) applyTheme() { + want := "" + if cfg, err := config.New(); err == nil { + want = cfg.GetString("tui.theme") + } + if want == "" { + if termenv.HasDarkBackground() { + want = "dark" + } else { + want = "light" + } + } + m.setTheme(want, false) +} + +// setTheme swaps the palette and rebuilds styles. When persist is true +// the choice is saved to config. +func (m *Model) setTheme(want string, persist bool) { + if want == "light" { + styles.SetLight() + } else { + styles.SetDark() + } + styles.Refresh() + if persist { + if cfg, err := config.New(); err == nil { + cfg.Set("tui.theme", want) + _ = cfg.Save() + } + } +} + +// themeName reports the active theme for toggles and help. +func (m *Model) themeName() string { + if styles.Dark { + return "dark" + } + return "light" +} diff --git a/pkg/tui/styles/colors.go b/pkg/tui/styles/colors.go index 65bf6fb..a350b43 100644 --- a/pkg/tui/styles/colors.go +++ b/pkg/tui/styles/colors.go @@ -1,27 +1,28 @@ -// Package styles provides a clean, accessible color theme for the TUI. -// Based on Catppuccin Mocha with good contrast ratios. +// Package styles provides the TUI color theme: Catppuccin Mocha (dark) +// with a Latte (light) variant. Colors are variables so the theme can +// switch at runtime; every view reads these tokens, never literals. package styles import "github.com/charmbracelet/lipgloss" -// Color palette — accessible, high-contrast, modern. -const ( - Base = lipgloss.Color("#1e1e2e") // dark background - Surface = lipgloss.Color("#181825") // darker surface - Overlay = lipgloss.Color("#313244") // elevated surface - Text = lipgloss.Color("#cdd6f4") // primary text - Subtext = lipgloss.Color("#a6adc8") // secondary text - Overlay0 = lipgloss.Color("#6c7086") // muted - Blue = lipgloss.Color("#89b4fa") // primary accent - Green = lipgloss.Color("#a6e3a1") // success - Yellow = lipgloss.Color("#f9e2af") // warning - Red = lipgloss.Color("#f38ba8") // error/danger - Mauve = lipgloss.Color("#cba6f7") // purple accent - Peach = lipgloss.Color("#fab387") // orange - Teal = lipgloss.Color("#94e2d5") // cyan - Rosewater = lipgloss.Color("#f5e0dc") // pinkish +// Color palette — dark (Mocha) defaults; SetLight/SetDark swap them. +var ( + Base = lipgloss.Color("#1e1e2e") + Surface = lipgloss.Color("#181825") + Overlay = lipgloss.Color("#313244") + Text = lipgloss.Color("#cdd6f4") + Subtext = lipgloss.Color("#a6adc8") + Overlay0 = lipgloss.Color("#6c7086") + Blue = lipgloss.Color("#89b4fa") + Green = lipgloss.Color("#a6e3a1") + Yellow = lipgloss.Color("#f9e2af") + Red = lipgloss.Color("#f38ba8") + Mauve = lipgloss.Color("#cba6f7") + Peach = lipgloss.Color("#fab387") + Teal = lipgloss.Color("#94e2d5") + Rosewater = lipgloss.Color("#f5e0dc") - // Semantic aliases + // Semantic aliases (re-pointed on theme switch) Success = Green Warning = Yellow Danger = Red @@ -32,4 +33,61 @@ const ( // Panel border colors ActivePanelBorderColor = Accent InactivePanelBorderColor = Overlay0 + + // Dark reports whether the dark palette is active. + Dark = true ) + +// SetLight swaps to the Catppuccin Latte palette. +func SetLight() { + Base = "#eff1f5" + Surface = "#e6e9ef" + Overlay = "#ccd0da" + Text = "#4c4f69" + Subtext = "#6c6f85" + Overlay0 = "#9ca0b0" + Blue = "#1e66f5" + Green = "#40a02b" + Yellow = "#df8e1d" + Red = "#d20f39" + Mauve = "#8839ef" + Peach = "#fe640b" + Teal = "#179299" + Rosewater = "#dc8a78" + Success = Green + Warning = Yellow + Danger = Red + Info = Blue + Accent = Mauve + Selection = lipgloss.Color("#acb0be") + ActivePanelBorderColor = Accent + InactivePanelBorderColor = Overlay0 + Dark = false +} + +// SetDark restores the Catppuccin Mocha palette. +func SetDark() { + Base = "#1e1e2e" + Surface = "#181825" + Overlay = "#313244" + Text = "#cdd6f4" + Subtext = "#a6adc8" + Overlay0 = "#6c7086" + Blue = "#89b4fa" + Green = "#a6e3a1" + Yellow = "#f9e2af" + Red = "#f38ba8" + Mauve = "#cba6f7" + Peach = "#fab387" + Teal = "#94e2d5" + Rosewater = "#f5e0dc" + Success = Green + Warning = Yellow + Danger = Red + Info = Blue + Accent = Mauve + Selection = lipgloss.Color("#45475a") + ActivePanelBorderColor = Accent + InactivePanelBorderColor = Overlay0 + Dark = true +} diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index 475209c..dc75b62 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -6,133 +6,173 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Common style definitions used across all views. +// Common style definitions used across all views. Declared once, built +// by Refresh() from the current tokens so SetLight/SetDark take effect. var ( + AppStyle lipgloss.Style + ActiveTabStyle lipgloss.Style + InactiveTabStyle lipgloss.Style + HeaderLogoStyle lipgloss.Style + HeaderMetaStyle lipgloss.Style + HeaderBranchStyle lipgloss.Style + ContentStyle lipgloss.Style + StatusBarStyle lipgloss.Style + StatusBarBranchStyle lipgloss.Style + StatusBarInfoStyle lipgloss.Style + HintStyle lipgloss.Style + ListItemStyle lipgloss.Style + ListItemSelectedStyle lipgloss.Style + FilterBarStyle lipgloss.Style + ListItemActiveStyle lipgloss.Style + SectionTitleStyle lipgloss.Style + SubtitleStyle lipgloss.Style + StatusStagedStyle lipgloss.Style + StatusUnstagedStyle lipgloss.Style + StatusUntrackedStyle lipgloss.Style + StatusDeletedStyle lipgloss.Style + DiffAddStyle lipgloss.Style + DiffDelStyle lipgloss.Style + DiffHeaderStyle lipgloss.Style + HelpKeyStyle lipgloss.Style + HelpDescStyle lipgloss.Style + LoadingStyle lipgloss.Style + ErrorStyle lipgloss.Style + DialogBoxStyle lipgloss.Style + DialogTitleStyle lipgloss.Style +) + +// Refresh rebuilds every style from the current tokens. +// Call after SetLight/SetDark. +func Refresh() { // App-level AppStyle = lipgloss.NewStyle(). - Padding(0, 1). - Width(80) + Padding(0, 1). + Width(80) // Tabs ActiveTabStyle = lipgloss.NewStyle(). - Padding(0, 2). - Background(Blue). - Foreground(Base). - Bold(true) + Padding(0, 2). + Background(Blue). + Foreground(Base). + Bold(true) InactiveTabStyle = lipgloss.NewStyle(). - Padding(0, 2). - Background(Overlay). - Foreground(Subtext) + Padding(0, 2). + Background(Overlay). + Foreground(Subtext) // Header bar (top tab strip) HeaderLogoStyle = lipgloss.NewStyle(). - Padding(0, 1). - Background(Mauve). - Foreground(Base). - Bold(true) + Padding(0, 1). + Background(Mauve). + Foreground(Base). + Bold(true) HeaderMetaStyle = lipgloss.NewStyle(). - Padding(0, 1). - Foreground(Subtext) + Padding(0, 1). + Foreground(Subtext) HeaderBranchStyle = lipgloss.NewStyle(). - Padding(0, 1). - Foreground(Teal). - Bold(true) + Padding(0, 1). + Foreground(Teal). + Bold(true) // Content areas ContentStyle = lipgloss.NewStyle(). - Padding(0, 1). - MarginTop(0) + Padding(0, 1). + MarginTop(0) // Status bar StatusBarStyle = lipgloss.NewStyle(). - Background(Surface). - Foreground(Subtext). - Padding(0, 1). - Width(80). - Height(1) + Background(Surface). + Foreground(Subtext). + Padding(0, 1). + Width(80). + Height(1) StatusBarBranchStyle = lipgloss.NewStyle(). - Foreground(Teal). - Bold(true) + Foreground(Teal). + Bold(true) StatusBarInfoStyle = lipgloss.NewStyle(). - Foreground(Subtext) + Foreground(Subtext) HintStyle = lipgloss.NewStyle(). - Foreground(Overlay0) + Foreground(Overlay0) // Lists ListItemStyle = lipgloss.NewStyle(). - Foreground(Text) + Foreground(Text) ListItemSelectedStyle = lipgloss.NewStyle(). - PaddingLeft(1).Background(Selection). - Foreground(Text). - Bold(true) + PaddingLeft(1).Background(Selection). + Foreground(Text). + Bold(true) FilterBarStyle = lipgloss.NewStyle(). - Foreground(Yellow) + Foreground(Yellow) ListItemActiveStyle = lipgloss.NewStyle(). - Foreground(Blue). - Bold(true) + Foreground(Blue). + Bold(true) // Headers SectionTitleStyle = lipgloss.NewStyle(). - Foreground(Blue). - Bold(true). - MarginTop(1). - MarginBottom(1). - PaddingLeft(1) + Foreground(Blue). + Bold(true). + MarginTop(1). + MarginBottom(1). + PaddingLeft(1) SubtitleStyle = lipgloss.NewStyle(). - Foreground(Subtext). - Italic(true) + Foreground(Subtext). + Italic(true) // File status indicators StatusStagedStyle = lipgloss.NewStyle(). - Foreground(Green). - Bold(true) + Foreground(Green). + Bold(true) StatusUnstagedStyle = lipgloss.NewStyle(). - Foreground(Yellow) + Foreground(Yellow) StatusUntrackedStyle = lipgloss.NewStyle(). - Foreground(Subtext) + Foreground(Subtext) StatusDeletedStyle = lipgloss.NewStyle(). - Foreground(Red). - Strikethrough(true) + Foreground(Red). + Strikethrough(true) // Diff DiffAddStyle = lipgloss.NewStyle(). - Foreground(Green). - Background(lipgloss.Color("#1a3a1a")) + Foreground(Green). + Background(lipgloss.Color("#1a3a1a")) DiffDelStyle = lipgloss.NewStyle(). - Foreground(Red). - Background(lipgloss.Color("#3a1a1a")) + Foreground(Red). + Background(lipgloss.Color("#3a1a1a")) DiffHeaderStyle = lipgloss.NewStyle(). - Foreground(Blue). - Bold(true) + Foreground(Blue). + Bold(true) // Help HelpKeyStyle = lipgloss.NewStyle(). - Foreground(Blue). - Bold(true) + Foreground(Blue). + Bold(true) HelpDescStyle = lipgloss.NewStyle(). - Foreground(Subtext) + Foreground(Subtext) // Loading LoadingStyle = lipgloss.NewStyle(). - Foreground(Subtext). - Italic(true) + Foreground(Subtext). + Italic(true) // Error ErrorStyle = lipgloss.NewStyle(). - Foreground(Red). - Bold(true) + Foreground(Red). + Bold(true) // Dialog DialogBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(Blue). - Padding(1, 2). - Background(Surface) + Border(lipgloss.RoundedBorder()). + BorderForeground(Blue). + Padding(1, 2). + Background(Surface) DialogTitleStyle = lipgloss.NewStyle(). - Foreground(Blue). - Bold(true) -) + Foreground(Blue). + Bold(true) +} + +func init() { + Refresh() +} // Divider returns a horizontal line. func Divider() string { diff --git a/pkg/tui/views/golden_test.go b/pkg/tui/views/golden_test.go new file mode 100644 index 0000000..ec3bc69 --- /dev/null +++ b/pkg/tui/views/golden_test.go @@ -0,0 +1,66 @@ +package views + +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/zaidejjo/zgit/pkg/core/models" +) + +var goldenANSI = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]") + +// assertGolden compares plain (ANSI-stripped) view output to testdata. +// Regenerate with UPDATE_GOLDEN=1 after deliberate visual changes. +func assertGolden(t *testing.T, name, got string) { + t.Helper() + plain := goldenANSI.ReplaceAllString(got, "") + path := filepath.Join("testdata", name+".golden") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(plain), 0644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("golden %s missing (run with UPDATE_GOLDEN=1): %v", path, err) + } + if plain != string(want) { + t.Fatalf("golden %s mismatch:\n--- got ---\n%s\n--- want ---\n%s", name, plain, want) + } +} + +func TestGoldenDashboard(t *testing.T) { + m := NewDashModel() + m.Height = 30 + m.UpdateEntries([]DashEntry{ + {Name: "zgit", Path: "/r/zgit", Branch: "dev", Clean: true, Favorite: true, Current: true}, + {Name: "zz_lang", Path: "/r/zz_lang", Branch: "main", Clean: false, Dirty: 3, Ahead: 1}, + {Name: "gone", Path: "/r/gone", Err: "unreachable"}, + }) + assertGolden(t, "dashboard", m.View(100)) +} + +func TestGoldenNotes(t *testing.T) { + m := NewNotesModel() + m.Height = 30 + m.UpdateNotes([]*models.Notification{ + {ID: "1", Title: "Review requested", Type: "PullRequest", Reason: "review_requested", RepoName: "o/r", Unread: true}, + {ID: "2", Title: "Mentioned you", Type: "Issue", Reason: "mention", RepoName: "o/r"}, + }) + assertGolden(t, "notes", m.View(100)) +} + +func TestGoldenActions(t *testing.T) { + m := NewActionsModel() + m.Height = 30 + m.UpdateRuns([]*models.WorkflowRun{ + {ID: 1, WorkflowName: "CI", Branch: "dev", HeadSHA: "abc1234def", RunNumber: 10, Status: "COMPLETED", Conclusion: "SUCCESS"}, + {ID: 2, WorkflowName: "Docs", Branch: "main", HeadSHA: "def5678", RunNumber: 3, Status: "IN_PROGRESS"}, + }) + assertGolden(t, "actions", m.View(100)) +} diff --git a/pkg/tui/views/help.go b/pkg/tui/views/help.go index 2cb19c8..fb53d1a 100644 --- a/pkg/tui/views/help.go +++ b/pkg/tui/views/help.go @@ -57,6 +57,13 @@ func allSections() []helpSection { {"/cmd", "Slash commands: /models, /providers, /key, /clear, /new, /chats, /help"}, }, }, + { + title: "Display", + keys: []helpEntry{ + {"L", "Toggle light / dark theme"}, + {"wheel", "Scroll focused list (zgit tui --mouse)"}, + }, + }, { title: "Config", keys: []helpEntry{ diff --git a/pkg/tui/views/palette.go b/pkg/tui/views/palette.go index 9acf509..e04d57c 100644 --- a/pkg/tui/views/palette.go +++ b/pkg/tui/views/palette.go @@ -9,18 +9,26 @@ import ( "github.com/zaidejjo/zgit/pkg/tui/styles" ) -// SelectionStyle is the highlighted item style. -var SelectionStyle = lipgloss.NewStyle().Background(styles.Mauve).Foreground(styles.Base).Bold(true) +// SelectionStyle is the highlighted item style (built per render so +// theme switches apply). +func SelectionStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(styles.Mauve).Foreground(styles.Base).Bold(true) +} // accentStyle is for keybinding hints (normal rows). -var accentStyle = lipgloss.NewStyle().Foreground(styles.Mauve) +func accentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.Mauve) +} // accentStyleSelected is for keybinding hints on the selected row. -// Uses dark foreground to contrast with Mauve background. -var accentStyleSelected = lipgloss.NewStyle().Foreground(styles.Surface).Bold(true) +func accentStyleSelected() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.Surface).Bold(true) +} // mutedStyle is for secondary text. -var mutedStyle = lipgloss.NewStyle().Foreground(styles.Subtext) +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.Subtext) +} const maxVisibleResults = 7 @@ -332,9 +340,9 @@ func (m PaletteModel) View(width int) string { keysStr := "" if cmd.Keys != "" { if isSelected { - keysStr = " " + accentStyleSelected.Render(cmd.Keys) + keysStr = " " + accentStyleSelected().Render(cmd.Keys) } else { - keysStr = " " + accentStyle.Render(cmd.Keys) + keysStr = " " + accentStyle().Render(cmd.Keys) } } @@ -373,10 +381,10 @@ func (m PaletteModel) View(width int) string { } if isSelected { - b.WriteString(SelectionStyle.Render(line)) + b.WriteString(SelectionStyle().Render(line)) b.WriteString("\n") // Description on next line, indented - desc := mutedStyle.Render(" " + cmd.Description) + desc := mutedStyle().Render(" " + cmd.Description) b.WriteString(desc) b.WriteString("\n") } else { diff --git a/pkg/tui/views/testdata/actions.golden b/pkg/tui/views/testdata/actions.golden new file mode 100644 index 0000000..145a30e --- /dev/null +++ b/pkg/tui/views/testdata/actions.golden @@ -0,0 +1,5 @@ + + Actions (2) + +❯● CI #10 dev abc1234 + ◉ Docs #3 main def5678 diff --git a/pkg/tui/views/testdata/dashboard.golden b/pkg/tui/views/testdata/dashboard.golden new file mode 100644 index 0000000..6afc325 --- /dev/null +++ b/pkg/tui/views/testdata/dashboard.golden @@ -0,0 +1,6 @@ + + Repositories (3) + +❯● ★ zgit ‹open› dev + ● zz_lang main 3 dirty +1/-0 + ✗ gone unreachable diff --git a/pkg/tui/views/testdata/notes.golden b/pkg/tui/views/testdata/notes.golden new file mode 100644 index 0000000..ad3b651 --- /dev/null +++ b/pkg/tui/views/testdata/notes.golden @@ -0,0 +1,5 @@ + + Notifications [Unread] (2) + +❯● ◈ Review requested o/r [review requested] + ● Mentioned you o/r [mention]