Add unified TUI theme package with bubbletint

Amolith created

Change summary

internal/theme/theme.go      | 203 ++++++++++++++++++++++++++++++++++++++
internal/theme/theme_test.go | 144 ++++++++++++++++++++++++++
2 files changed, 347 insertions(+)

Detailed changes

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."),
+	}
+}

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, "β–Έ ")
+	}
+}