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