From 9503d23dddb4cb886195a2c79f1763876e0dbde6 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 28 Mar 2026 17:53:30 -0600 Subject: [PATCH] Add unified TUI theme package with bubbletint --- internal/theme/theme.go | 203 +++++++++++++++++++++++++++++++++++ internal/theme/theme_test.go | 144 +++++++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 internal/theme/theme.go create mode 100644 internal/theme/theme_test.go diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000000000000000000000000000000000000..0521489a7e9436c36fc1ef8e608515194f365395 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,203 @@ +// Package theme provides a single source of truth for keld's TUI styling. +// +// It uses bubbletint's Rosé Pine palettes (Moon for dark terminals, Dawn +// for light) and exposes semantic colour helpers plus pre-built themes +// for huh forms, the bubbles help bar, and the vendored file picker. +// +// Call [New] once at startup after detecting the terminal background. +// The returned [Styles] struct is then passed to all UI components. +package theme + +import ( + "image/color" + + "charm.land/bubbles/v2/help" + "charm.land/lipgloss/v2" + + "charm.land/huh/v2" + + tint "github.com/lrstanley/bubbletint/v2" + + "git.secluded.site/keld/internal/picker" +) + +// Cursor is the string used to indicate the highlighted item in any +// list. Every selectable-list screen should use this. +const Cursor = "▸ " + +var ( + paletteDark = tint.TintRosePineMoon + paletteLight = tint.TintRosePineDawn +) + +// Styles holds all pre-computed styles for keld's TUI. Create one +// with [New] after detecting the terminal background and pass it to +// every UI component. Do not create styles elsewhere. +type Styles struct { + // Dark reports whether the dark palette is active. + Dark bool + + // Semantic colours derived from the active Rosé Pine palette. + // Use these when building ad-hoc styled strings; prefer the + // pre-built style fields below when possible. + Accent color.Color // iris (purple) — cursor, focused borders, titles + Secondary color.Color // foam (teal) — directory names, filter prompts + Warning color.Color // love (red) — error indicators, validation + Confirm color.Color // foam (teal) — confirmation, success + + // Chrome styles for the session frame. + Breadcrumb lipgloss.Style + Title lipgloss.Style + + // Pre-built component themes, computed once. + Help help.Styles + Huh huh.Theme + Picker picker.Styles +} + +// New creates a [Styles] from the given dark/light setting. Call once +// after receiving [tea.BackgroundColorMsg] and thread the result +// through to all UI components. +func New(isDark bool) Styles { + p := paletteLight + if isDark { + p = paletteDark + } + + accent := p.Purple + secondary := p.Green + warn := p.Red + confirm := p.Green + + // Buttons invert the normal text/background relationship. On + // dark terminals the palette foreground is light; on light + // terminals the palette background is light. Both contrast + // well against the purple accent. + // + // TODO: evaluate contrast of Moon Fg (#e0def3) on Moon iris + // (#c3a7e7). It's the brightest colour the palette offers, + // but the pairing may still be too low-contrast for some + // displays. Revisit once the session shell is wired up and + // we can eyeball it in a real terminal. + buttonFg := color.Color(p.Fg) + if !isDark { + buttonFg = p.Bg + } + + return Styles{ + Dark: isDark, + Accent: accent, + Secondary: secondary, + Warning: warn, + Confirm: confirm, + + Breadcrumb: lipgloss.NewStyle().Foreground(accent), + Title: lipgloss.NewStyle().Bold(true), + + Help: buildHelpStyles(accent), + Huh: buildHuhTheme(isDark, accent, secondary, warn, confirm, buttonFg), + Picker: buildPickerStyles(accent, secondary), + } +} + +// ── Help bar ───────────────────────────────────────────────── + +// buildHelpStyles creates [help.Styles] for the bubbles help bar. +// Keys are bold; descriptions and separators use readable colours +// with no muted or dim text. +func buildHelpStyles(accent color.Color) help.Styles { + sep := lipgloss.NewStyle().Foreground(accent) + + return help.Styles{ + Ellipsis: lipgloss.NewStyle(), + ShortKey: lipgloss.NewStyle().Bold(true), + ShortDesc: lipgloss.NewStyle(), + ShortSeparator: sep, + FullKey: lipgloss.NewStyle().Bold(true), + FullDesc: lipgloss.NewStyle(), + FullSeparator: sep, + } +} + +// ── huh form theme ─────────────────────────────────────────── + +// buildHuhTheme creates an [huh.Theme] that applies the active +// palette to huh forms. It builds on huh's base theme and overrides +// colours to match keld's visual identity. +// +// The isDark parameter is captured at build time so the ThemeFunc +// callback always uses the same palette as the rest of the session, +// regardless of what huh passes in. +func buildHuhTheme( + isDark bool, + accent, secondary, warn, confirm, buttonFg color.Color, +) huh.Theme { + // Build the huh styles once and close over the result. The + // ThemeFunc callback may be invoked on every render tick, so + // returning a pre-computed value avoids repeated allocation. + t := huh.ThemeBase(isDark) + + // Focused field styles. + t.Focused.Base = t.Focused.Base.BorderForeground(accent) + t.Focused.Card = t.Focused.Base + t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(accent).Bold(true).MarginBottom(1) + t.Focused.Directory = t.Focused.Directory.Foreground(secondary) + // Description left unstyled — terminal default foreground. + t.Focused.Description = t.Focused.Description.UnsetForeground() + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(warn) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(warn) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString(Cursor) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(accent) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(accent) + // Options left unstyled — terminal default foreground. + t.Focused.Option = t.Focused.Option.UnsetForeground() + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(accent).SetString(Cursor) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(confirm) + t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(confirm).SetString("✓ ") + // Unselected items use terminal default — no dimming. + t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("• ") + t.Focused.UnselectedOption = t.Focused.UnselectedOption.UnsetForeground() + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(buttonFg).Background(accent) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.BlurredButton = t.Focused.BlurredButton.UnsetForeground().UnsetBackground() + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) + // Placeholder left at terminal default — no dimming. + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.UnsetForeground() + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) + + // Blurred styles: same as focused but with a hidden border so + // alignment is preserved without the visual weight. + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + + // Help bar within huh forms — same style as the session help. + t.Help = buildHelpStyles(accent) + + return huh.ThemeFunc(func(_ bool) *huh.Styles { return t }) +} + +// ── File picker styles ─────────────────────────────────────── + +// buildPickerStyles creates [picker.Styles] derived from the palette. +func buildPickerStyles(accent, secondary color.Color) picker.Styles { + return picker.Styles{ + Cursor: lipgloss.NewStyle().Foreground(accent), + Directory: lipgloss.NewStyle().Foreground(secondary), + File: lipgloss.NewStyle(), // terminal default + // Permissions and file sizes: unstyled, terminal default. + // No muted text. + Permission: lipgloss.NewStyle(), + Selected: lipgloss.NewStyle().Foreground(accent).Bold(true), + FileSize: lipgloss.NewStyle().Width(7).Align(lipgloss.Right), + EmptyDirectory: lipgloss.NewStyle(). + PaddingLeft(2). + SetString("No files in this directory."), + } +} diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ad68ea673dcb07d4503e0cdbf05f0ac43945a74e --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,144 @@ +package theme + +import ( + "image/color" + "testing" +) + +func TestNewDark(t *testing.T) { + s := New(true) + + if !s.Dark { + t.Error("New(true).Dark = false, want true") + } +} + +func TestNewLight(t *testing.T) { + s := New(false) + + if s.Dark { + t.Error("New(false).Dark = true, want false") + } +} + +func TestSemanticColoursNotNil(t *testing.T) { + t.Parallel() + + for _, isDark := range []bool{true, false} { + name := "dark" + if !isDark { + name = "light" + } + + t.Run(name, func(t *testing.T) { + t.Parallel() + + s := New(isDark) + colours := map[string]color.Color{ + "Accent": s.Accent, + "Secondary": s.Secondary, + "Warning": s.Warning, + "Confirm": s.Confirm, + } + for label, c := range colours { + if c == nil { + t.Errorf("%s is nil", label) + } + } + }) + } +} + +func TestHuhThemeReturnsBothPalettes(t *testing.T) { + t.Parallel() + + for _, isDark := range []bool{true, false} { + name := "dark" + if !isDark { + name = "light" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + + s := New(isDark) + // The ThemeFunc is called with an arbitrary bool; it + // should always use the isDark captured at build time. + styles := s.Huh.Theme(isDark) + if styles == nil { + t.Fatal("Theme() returned nil Styles") + } + }) + } +} + +func TestHuhThemeIgnoresCallbackParameter(t *testing.T) { + t.Parallel() + + // Build a dark theme, then call the ThemeFunc with isDark=false. + // The returned styles should still reflect the dark palette + // because the callback parameter is intentionally ignored. + dark := New(true) + light := New(false) + + fromDark := dark.Huh.Theme(false) // pass false, should still get dark + fromLight := light.Huh.Theme(true) // pass true, should still get light + + if fromDark == nil { + t.Fatal("Theme(false) on dark Styles returned nil") + } + if fromLight == nil { + t.Fatal("Theme(true) on light Styles returned nil") + } + + // Verify the title colour matches the expected palette accent, + // not the opposite palette. We compare the rendered output of + // the title style — if huh's isDark parameter were used instead + // of ours, the foreground colour would differ. + // + // Skip when lipgloss strips ANSI (e.g. TERM=dumb, NO_COLOR), + // because both renders would collapse to plain "x". + darkTitle := fromDark.Focused.Title.Render("x") + lightTitle := fromLight.Focused.Title.Render("x") + if darkTitle == "x" && lightTitle == "x" { + t.Skip("colour output unavailable; cannot compare palette rendering") + } + if darkTitle == lightTitle { + t.Error("dark and light huh themes produced identical title rendering; isDark parameter may not be ignored") + } +} + +func TestPickerStylesEmptyDirectoryMessage(t *testing.T) { + t.Parallel() + + s := New(true) + got := s.Picker.EmptyDirectory.Value() + want := "No files in this directory." + if got != want { + t.Errorf("EmptyDirectory string = %q, want %q", got, want) + } +} + +func TestHelpStylesNoDimText(t *testing.T) { + t.Parallel() + + s := New(true) + + // ShortDesc and FullDesc should have no foreground set (they + // inherit the terminal default). We verify by checking that the + // style renders without injecting a colour sequence. + plain := "test" + if got := s.Help.ShortDesc.Render(plain); got != plain { + t.Errorf("ShortDesc styled unexpectedly: got %q, want %q", got, plain) + } + if got := s.Help.FullDesc.Render(plain); got != plain { + t.Errorf("FullDesc styled unexpectedly: got %q, want %q", got, plain) + } +} + +func TestCursorConstant(t *testing.T) { + t.Parallel() + + if Cursor != "▸ " { + t.Errorf("Cursor = %q, want %q", Cursor, "▸ ") + } +}