theme.go

  1// Package theme provides a single source of truth for keld's TUI styling.
  2//
  3// It uses bubbletint's RosΓ© Pine palettes (Moon for dark terminals, Dawn
  4// for light) and exposes semantic colour helpers plus pre-built themes
  5// for huh forms, the bubbles help bar, and the vendored file picker.
  6//
  7// Call [New] once at startup after detecting the terminal background.
  8// The returned [Styles] struct is then passed to all UI components.
  9package theme
 10
 11import (
 12	"image/color"
 13
 14	"charm.land/bubbles/v2/help"
 15	"charm.land/lipgloss/v2"
 16
 17	"charm.land/huh/v2"
 18
 19	tint "github.com/lrstanley/bubbletint/v2"
 20
 21	"git.secluded.site/keld/internal/picker"
 22)
 23
 24// Cursor is the string used to indicate the highlighted item in any
 25// list. Every selectable-list screen should use this.
 26const Cursor = "β–Έ "
 27
 28var (
 29	paletteDark  = tint.TintRosePineMoon
 30	paletteLight = tint.TintRosePineDawn
 31)
 32
 33// Styles holds all pre-computed styles for keld's TUI. Create one
 34// with [New] after detecting the terminal background and pass it to
 35// every UI component. Do not create styles elsewhere.
 36type Styles struct {
 37	// Dark reports whether the dark palette is active.
 38	Dark bool
 39
 40	// Semantic colours derived from the active RosΓ© Pine palette.
 41	// Use these when building ad-hoc styled strings; prefer the
 42	// pre-built style fields below when possible.
 43	Accent    color.Color // iris (purple) β€” cursor, focused borders, titles
 44	Secondary color.Color // foam (teal) β€” directory names, filter prompts
 45	Warning   color.Color // love (red) β€” error indicators, validation
 46	Confirm   color.Color // foam (teal) β€” confirmation, success
 47
 48	// Chrome styles for the session frame.
 49	Breadcrumb lipgloss.Style
 50	Title      lipgloss.Style
 51
 52	// Pre-built component themes, computed once.
 53	Help   help.Styles
 54	Huh    huh.Theme
 55	Picker picker.Styles
 56}
 57
 58// New creates a [Styles] from the given dark/light setting. Call once
 59// after receiving [tea.BackgroundColorMsg] and thread the result
 60// through to all UI components.
 61func New(isDark bool) Styles {
 62	p := paletteLight
 63	if isDark {
 64		p = paletteDark
 65	}
 66
 67	accent := p.Purple
 68	secondary := p.Green
 69	warn := p.Red
 70	confirm := p.Green
 71
 72	// Buttons invert the normal text/background relationship. On
 73	// dark terminals the palette foreground is light; on light
 74	// terminals the palette background is light. Both contrast
 75	// well against the purple accent.
 76	//
 77	// TODO: evaluate contrast of Moon Fg (#e0def3) on Moon iris
 78	// (#c3a7e7). It's the brightest colour the palette offers,
 79	// but the pairing may still be too low-contrast for some
 80	// displays. Revisit once the session shell is wired up and
 81	// we can eyeball it in a real terminal.
 82	buttonFg := color.Color(p.Fg)
 83	if !isDark {
 84		buttonFg = p.Bg
 85	}
 86
 87	return Styles{
 88		Dark:      isDark,
 89		Accent:    accent,
 90		Secondary: secondary,
 91		Warning:   warn,
 92		Confirm:   confirm,
 93
 94		Breadcrumb: lipgloss.NewStyle().Foreground(accent),
 95		Title:      lipgloss.NewStyle().Bold(true),
 96
 97		Help:   buildHelpStyles(accent),
 98		Huh:    buildHuhTheme(isDark, accent, secondary, warn, confirm, buttonFg),
 99		Picker: buildPickerStyles(accent, secondary),
100	}
101}
102
103// ── Help bar ─────────────────────────────────────────────────
104
105// buildHelpStyles creates [help.Styles] for the bubbles help bar.
106// Keys are bold; descriptions and separators use readable colours
107// with no muted or dim text.
108func buildHelpStyles(accent color.Color) help.Styles {
109	sep := lipgloss.NewStyle().Foreground(accent)
110
111	return help.Styles{
112		Ellipsis:       lipgloss.NewStyle(),
113		ShortKey:       lipgloss.NewStyle().Bold(true),
114		ShortDesc:      lipgloss.NewStyle(),
115		ShortSeparator: sep,
116		FullKey:        lipgloss.NewStyle().Bold(true),
117		FullDesc:       lipgloss.NewStyle(),
118		FullSeparator:  sep,
119	}
120}
121
122// ── huh form theme ───────────────────────────────────────────
123
124// buildHuhTheme creates an [huh.Theme] that applies the active
125// palette to huh forms. It builds on huh's base theme and overrides
126// colours to match keld's visual identity.
127//
128// The isDark parameter is captured at build time so the ThemeFunc
129// callback always uses the same palette as the rest of the session,
130// regardless of what huh passes in.
131func buildHuhTheme(
132	isDark bool,
133	accent, secondary, warn, confirm, buttonFg color.Color,
134) huh.Theme {
135	// Build the huh styles once and close over the result. The
136	// ThemeFunc callback may be invoked on every render tick, so
137	// returning a pre-computed value avoids repeated allocation.
138	t := huh.ThemeBase(isDark)
139
140	// Focused field styles.
141	t.Focused.Base = t.Focused.Base.BorderForeground(accent)
142	t.Focused.Card = t.Focused.Base
143	t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true)
144	t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(accent).Bold(true).MarginBottom(1)
145	t.Focused.Directory = t.Focused.Directory.Foreground(secondary)
146	// Description left unstyled β€” terminal default foreground.
147	t.Focused.Description = t.Focused.Description.UnsetForeground()
148	t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(warn)
149	t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(warn)
150	t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString(Cursor)
151	t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(accent)
152	t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(accent)
153	// Options left unstyled β€” terminal default foreground.
154	t.Focused.Option = t.Focused.Option.UnsetForeground()
155	t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(accent).SetString(Cursor)
156	t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(confirm)
157	t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(confirm).SetString("βœ“ ")
158	// Unselected items use terminal default β€” no dimming.
159	t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("β€’ ")
160	t.Focused.UnselectedOption = t.Focused.UnselectedOption.UnsetForeground()
161	t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(buttonFg).Background(accent)
162	t.Focused.Next = t.Focused.FocusedButton
163	t.Focused.BlurredButton = t.Focused.BlurredButton.UnsetForeground().UnsetBackground()
164	t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent)
165	// Placeholder left at terminal default β€” no dimming.
166	t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.UnsetForeground()
167	t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent)
168
169	// Blurred styles: same as focused but with a hidden border so
170	// alignment is preserved without the visual weight.
171	t.Blurred = t.Focused
172	t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
173	t.Blurred.Card = t.Blurred.Base
174	t.Blurred.NextIndicator = lipgloss.NewStyle()
175	t.Blurred.PrevIndicator = lipgloss.NewStyle()
176
177	t.Group.Title = t.Focused.Title
178	t.Group.Description = t.Focused.Description
179
180	// Help bar within huh forms β€” same style as the session help.
181	t.Help = buildHelpStyles(accent)
182
183	return huh.ThemeFunc(func(_ bool) *huh.Styles { return t })
184}
185
186// ── File picker styles ───────────────────────────────────────
187
188// buildPickerStyles creates [picker.Styles] derived from the palette.
189func buildPickerStyles(accent, secondary color.Color) picker.Styles {
190	return picker.Styles{
191		Cursor:    lipgloss.NewStyle().Foreground(accent),
192		Directory: lipgloss.NewStyle().Foreground(secondary),
193		File:      lipgloss.NewStyle(), // terminal default
194		// Permissions and file sizes: unstyled, terminal default.
195		// No muted text.
196		Permission: lipgloss.NewStyle(),
197		Selected:   lipgloss.NewStyle().Foreground(accent).Bold(true),
198		FileSize:   lipgloss.NewStyle().Width(7).Align(lipgloss.Right),
199		EmptyDirectory: lipgloss.NewStyle().
200			PaddingLeft(2).
201			SetString("No files in this directory."),
202	}
203}