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}