diff --git a/README.md b/README.md index 037deb2f5..8bed5f548 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,14 @@ hey journal read # read today's entry (or pass YYYY-MM-DD) hey journal write "..." # write today's entry (or omit content for $EDITOR) ``` +## Omarchy + +On [Omarchy](https://omarchy.org) the TUI follows the active theme with no setup: it lays +the theme's `accent`, `selection`, `muted`, `foreground` and `red` over its ANSI palette, +read from `~/.local/state/omarchy/current/theme/`, and restyles live when you run +`omarchy theme set`. Set `HEY_THEME=/path/to/file.toml` to use your own overlay anywhere +— an explicitly chosen file is trusted as written — or `NO_COLOR=1` to turn color off. + ## Agent Skill hey-cli ships with an embedded agent skill so your agent can interact with HEY on your behalf. diff --git a/go.mod b/go.mod index 56c406213..97c74cea9 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/basecamp/actioncable-go v0.0.0-20260819125529-39dd29b8e4d1 github.com/basecamp/hey-sdk/go v0.7.0 github.com/charmbracelet/x/ansi v0.11.8 + github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 github.com/itchyny/gojq v0.12.19 github.com/mattn/go-runewidth v0.0.28 diff --git a/go.sum b/go.sum index f2f73ac6d..fbd53e1bf 100644 --- a/go.sum +++ b/go.sum @@ -127,6 +127,8 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index e1420adae..a63cfca17 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -189,6 +189,14 @@ func (v *calendarView) InThread() bool { return v.inThread } func (v *calendarView) ExitThread() { v.inThread = false } func (v *calendarView) Loading() bool { return v.loading } +// Restyle re-renders the day/week/year grid, which caches styled output in its +// viewport. The recording detail is plain text and needs nothing. +func (v *calendarView) Restyle() { + offset := v.contentVP.YOffset() + v.rebuildView() + v.contentVP.SetYOffset(offset) +} + func (v *calendarView) Resize(width, height int) { v.contentVP.SetWidth(width) v.contentVP.SetHeight(height) diff --git a/internal/tui/contacts.go b/internal/tui/contacts.go index a8e9481eb..2ff02b7e9 100644 --- a/internal/tui/contacts.go +++ b/internal/tui/contacts.go @@ -437,6 +437,22 @@ func (v *contactsView) Resize(width, height int) { func (v *contactsView) Loading() bool { return v.loading } +// Restyle re-renders the cached contact detail and hands the new styles to any +// open form. The contact list renders live. +func (v *contactsView) Restyle() { + if v.inDetail { + offset := v.detailView.YOffset() + v.detailView.SetContent(v.renderContactDetail()) + v.detailView.SetYOffset(offset) + } + if v.contactForm != nil { + v.contactForm.styles = v.vc.styles + } + if v.noteForm != nil { + v.noteForm.styles = v.vc.styles + } +} + func (v *contactsView) beginRequest(kind contactRequestKind) (uint64, context.Context) { if v.requestCancel != nil { v.requestCancel() diff --git a/internal/tui/contacts_list.go b/internal/tui/contacts_list.go index e236215ff..d006fe6a9 100644 --- a/internal/tui/contacts_list.go +++ b/internal/tui/contacts_list.go @@ -82,7 +82,8 @@ func (l *contactList) view() string { } visible := max(l.height/2, 1) end := min(l.scrollOff+visible, len(l.contacts)) - selected := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + cursorMarker, selected := cursorStyles() + selectedGap := selectionStyle(lipgloss.NewStyle()) normal := lipgloss.NewStyle().Foreground(colorBright) muted := styleMuted @@ -92,14 +93,14 @@ func (l *contactList) view() string { cursor := i == l.cursor prefix := " " if cursor { - prefix = selected.Render("│") + " " + prefix = cursorMarker.Render("│") + selectedGap.Render(" ") } name := truncateStr(contact.Name, max(l.width-4, 10)) email := truncateStr(contact.EmailAddress, max(l.width-18, 10)) line2 := fmt.Sprintf("#%d %s", contact.ID, email) if cursor { fmt.Fprintf(&b, "%s%s\n", prefix, selected.Render(name)) - fmt.Fprintf(&b, "%s %s\n", selected.Render("│"), selected.Render(line2)) + fmt.Fprintf(&b, "%s%s%s\n", cursorMarker.Render("│"), selectedGap.Render(" "), selected.Render(line2)) } else { fmt.Fprintf(&b, "%s%s\n", prefix, normal.Render(name)) fmt.Fprintf(&b, " %s\n", muted.Render(line2)) diff --git a/internal/tui/content.go b/internal/tui/content.go index 7e61927e2..7e94adbbf 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -203,7 +203,8 @@ func (c *contentList) view() string { var b strings.Builder end := min(c.scrollOff+c.visibleItemsFrom(c.scrollOff), len(c.postings)) - cursorBar := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + cursorMarker, _ := cursorStyles() + selectedGap := selectionStyle(lipgloss.NewStyle()) unseenDot := lipgloss.NewStyle().Foreground(colorAlert).Bold(true) selectedMark := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) @@ -233,28 +234,35 @@ func (c *contentList) view() string { fmt.Fprintln(&b, sectionHeader(label, c.width)) } + // The cursor row renders bold on top of the base styles and, when the + // theme has a usable selection, paints every segment — gaps included — + // with the selection background so it reads as one highlighted line. emphasize := func(base lipgloss.Style) lipgloss.Style { if isCursor { - base = base.Bold(true) + base = selectionStyle(base.Bold(true)) } return base } + gapStyle, dot, mark := lipgloss.NewStyle(), unseenDot, selectedMark + if isCursor { + gapStyle, dot, mark = selectedGap, selectionStyle(unseenDot), selectionStyle(selectedMark) + } // Line 1: [│] [●] Subject (count) Nov 24, 2025 // The cursor shows only as the bar on the left; the row keeps its // seen/unseen colors. var line1 strings.Builder if isCursor { - line1.WriteString(cursorBar.Render("│") + " ") + line1.WriteString(cursorMarker.Render("│") + gapStyle.Render(" ")) } else { line1.WriteString(" ") } if _, isSelected := c.selected[p.ID]; isSelected { - line1.WriteString(selectedMark.Render("✓") + " ") + line1.WriteString(mark.Render("✓") + gapStyle.Render(" ")) } else if !p.Seen && !c.hideSeenState { - line1.WriteString(unseenDot.Render("●") + " ") + line1.WriteString(dot.Render("●") + gapStyle.Render(" ")) } else { - line1.WriteString(" ") + line1.WriteString(gapStyle.Render(" ")) } // Subject: Posting.Name is the thread title, Summary is the last message excerpt @@ -281,13 +289,13 @@ func (c *contentList) view() string { gap := max(textWidth-lipgloss.Width(subject)+2+dateCol-lipgloss.Width(date), 1) line1.WriteString(emphasize(subjectBase).Render(subject)) - line1.WriteString(strings.Repeat(" ", gap)) + line1.WriteString(gapStyle.Render(strings.Repeat(" ", gap))) line1.WriteString(emphasize(dateBase).Render(date)) // Line 2: [│] extension@ Creator Name — excerpt... var line2 strings.Builder if isCursor { - line2.WriteString(cursorBar.Render("│") + " ") + line2.WriteString(cursorMarker.Render("│") + gapStyle.Render(" ")) } else { line2.WriteString(" ") } diff --git a/internal/tui/help.go b/internal/tui/help.go index 9ebfbaa85..a5668091a 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -31,6 +31,10 @@ func (h *helpBar) setBindings(bindings []helpBinding) { h.bindings = bindings } +func (h *helpBar) setStyles(s styles) { + h.styles = s +} + // height returns the number of lines the help bar occupies. func (h helpBar) height() int { v := h.view() diff --git a/internal/tui/journal.go b/internal/tui/journal.go index 77bdf2dfa..372279718 100644 --- a/internal/tui/journal.go +++ b/internal/tui/journal.go @@ -124,6 +124,10 @@ func (v *journalView) InThread() bool { return v.inThread } func (v *journalView) ExitThread() {} // no-op: journal always shows content func (v *journalView) Loading() bool { return v.loading } +// Restyle is a no-op: the journal caches plain text and Kitty placeholders, neither +// of which carries palette colors. The date list renders live. +func (v *journalView) Restyle() {} + func (v *journalView) Resize(width, height int) { v.topicViewport.SetWidth(width) v.topicViewport.SetHeight(height) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 21b9ee4ac..32c758b6e 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1020,6 +1020,27 @@ func (v *mailView) CancelPendingDetail() bool { func (v *mailView) Loading() bool { return v.loading } +// Restyle rebuilds the cached thread content and hands the new styles to any open +// form. The Kitty image placeholders in imageContent encode image IDs as colors, so +// they are reused as-is rather than recolored. +func (v *mailView) Restyle() { + if v.inThread { + offset := v.topicViewport.YOffset() + v.rebuildTopicContent() + v.topicViewport.SetYOffset(offset) + } + if v.compose != nil { + v.compose.styles = v.vc.styles + } + if v.searchForm != nil { + v.searchForm.styles = v.vc.styles + } + if v.bulkReply != nil { + v.bulkReply.styles = v.vc.styles + v.bulkReply.resize(v.bulkReply.width, v.bulkReply.height) + } +} + func (v *mailView) Resize(width, height int) { if v.compose != nil { v.compose.resize(width, height) diff --git a/internal/tui/screener.go b/internal/tui/screener.go index 260d22b61..e257c13b3 100644 --- a/internal/tui/screener.go +++ b/internal/tui/screener.go @@ -155,6 +155,9 @@ func (v *screenerView) Init() tea.Cmd { return v.requestPending(1) } +// Restyle is a no-op: the screener keeps plain rows and styles them on every View. +func (v *screenerView) Restyle() {} + func (v *screenerView) Update(msg tea.Msg) (tea.Cmd, bool) { switch msg := msg.(type) { case screenerPendingLoadedMsg: @@ -556,7 +559,12 @@ func screenerRowName(row screenerRow) string { } func renderScreenerRows(pane *screenerPane, visible, width int) string { - selected := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + // The cursor row goes through the same helpers as Mail and Contacts so a + // theme's selection background tints every segment of it, gaps included. + // Every text segment on it takes the accent, as in Mail: the selection is + // gated for accent-on-selection contrast, not muted-on-selection. + marker, selected := cursorStyles() + selectedGap := selectionStyle(lipgloss.NewStyle()) normal := lipgloss.NewStyle().Foreground(colorBright) muted := lipgloss.NewStyle().Foreground(colorMuted) @@ -566,32 +574,24 @@ func renderScreenerRows(pane *screenerPane, visible, width int) string { row := pane.rows[index] isCursor := index == pane.cursor - prefix := " " - if isCursor { - prefix = selected.Render("│") + " " - } - trailing := truncateStr(row.trailing, max(width/2, 10)) name := truncateStr(screenerRowName(row), max(width-lipgloss.Width(trailing)-6, 10)) - gap := max(width-4-lipgloss.Width(name)-lipgloss.Width(trailing), 1) + gap := strings.Repeat(" ", max(width-4-lipgloss.Width(name)-lipgloss.Width(trailing), 1)) + detail := truncateStr(row.detail, max(width-6, 10)) - b.WriteString(prefix) if isCursor { - b.WriteString(selected.Render(name)) - } else { - b.WriteString(normal.Render(name)) + b.WriteString(marker.Render("│") + selectedGap.Render(" ") + selected.Render(name) + selectedGap.Render(gap)) + if trailing != "" { + b.WriteString(selected.Render(trailing)) + } + b.WriteString("\n" + marker.Render("│") + selectedGap.Render(" ") + selected.Render(detail) + "\n") + continue } - b.WriteString(strings.Repeat(" ", gap)) + b.WriteString(" " + normal.Render(name) + gap) if trailing != "" { b.WriteString(muted.Render(trailing)) } - b.WriteString("\n") - - detailPrefix := " " - if isCursor { - detailPrefix = selected.Render("│") + " " - } - b.WriteString(detailPrefix + muted.Render(truncateStr(row.detail, max(width-6, 10))) + "\n") + b.WriteString("\n " + muted.Render(detail) + "\n") } return b.String() } diff --git a/internal/tui/section_view.go b/internal/tui/section_view.go index 857ce3dbb..885ee2b17 100644 --- a/internal/tui/section_view.go +++ b/internal/tui/section_view.go @@ -62,6 +62,10 @@ type sectionView interface { // Loading reports whether the section is waiting on data. Loading() bool + + // Restyle re-renders anything the section cached with the previous palette. + // The new styles are already in viewContext when it is called. + Restyle() } // inputCapturer is implemented by section views that can open a text form. diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 73da6a208..b4a5d75b4 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -1,6 +1,8 @@ package tui import ( + "image/color" + "math" "strings" "charm.land/lipgloss/v2" @@ -14,12 +16,16 @@ import ( // Omarchy's mapping (default/themed/*.tpl): White=foreground, // BrightWhite=bright_foreground, BrightBlack=muted, Black=background, // and the color names map to their theme keys (BrightYellow=bright_yellow…). +// +// applyTheme lays a Theme's accent overlay on top of the themed slots. They are +// mutated only from the Bubble Tea event loop (startup and ThemeChangedMsg), +// which is also the only place that renders. var ( - colorPrimary = lipgloss.BrightBlue // titles, selected items, sender names - colorMuted = lipgloss.BrightBlack // decorative filler only — see styleMuted - colorBright = lipgloss.BrightWhite // emphasized text - colorAlert = lipgloss.Red // attention: Omarchy themes signal alerts with red - colorError = lipgloss.Red // errors + colorPrimary color.Color = lipgloss.BrightBlue // titles, selected items, sender names + colorMuted color.Color = lipgloss.BrightBlack // decorative filler only — see styleMuted + colorBright color.Color = lipgloss.BrightWhite // emphasized text + colorAlert = lipgloss.Red // attention: Omarchy themes signal alerts with red + colorError color.Color = lipgloss.Red // errors // Interface chrome (rules, tabs, hotkeys) follows eza's convention for // dates and directories: regular Blue for secondary chrome, bold Blue @@ -29,6 +35,9 @@ var ( // The selected tab uses eza's file-owner yellow. Tabs are always bold; // color alone marks the selection. colorActive = lipgloss.Yellow + + colorSelection color.Color // cursor row background; nil means none + colorOnAccent color.Color = lipgloss.Black // pill text on an accent-filled background ) // styleMuted dims the theme's default foreground with the SGR faint @@ -38,6 +47,80 @@ var ( // text, borders and separators. var styleMuted = lipgloss.NewStyle().Faint(true) +// applyTheme makes theme the active palette. Styles built before the call keep the +// old colors — rebuild them with newStyles. +func applyTheme(theme Theme) { + colorPrimary = theme.Accent + colorMuted = theme.Muted + colorBright = theme.Bright + colorError = theme.Error + colorSelection = nil + if theme.Selection != nil && (theme.Trusted || contrastRatio(theme.Accent, theme.Selection) >= minSelectionContrast) { + colorSelection = theme.Selection + } + // The pill keeps its classic black text on the ANSI accent, but a themed + // accent can be dark enough that black is unreadable on it — lupine's blue + // carries black at only 3.7:1 — so pick whichever of black or white reads + // better there. + colorOnAccent = lipgloss.Black + if nc, ok := theme.Accent.(lipgloss.NoColor); ok { + colorOnAccent = nc + } else if theme.Accent != color.Color(lipgloss.BrightBlue) && + contrastRatio(lipgloss.BrightWhite, theme.Accent) > contrastRatio(lipgloss.Black, theme.Accent) { + colorOnAccent = lipgloss.BrightWhite + } + if !theme.Dark && theme.Bright == lipgloss.BrightWhite { + // Bright white is the background on a light terminal; ANSI black is its text. + colorBright = lipgloss.Black + } +} + +// selectionStyle returns a style that paints the selection background when the +// theme has one, and the style unchanged otherwise. Every segment of a cursor row +// goes through it so the row reads as one highlighted line. +func selectionStyle(s lipgloss.Style) lipgloss.Style { + if colorSelection == nil { + return s + } + return s.Background(colorSelection) +} + +// applyTheme keeps colorSelection only when the accent still reads on it: the +// cursor row is accent-colored text, and on a third of Omarchy's themes the +// accent-on-selection pair falls under 3:1 while 4.5:1 is where bold text +// stays crisp. Below that the row keeps the accent and drops the tint. +const minSelectionContrast = 4.5 + +// cursorStyles returns the styles for the cursor row of a list: the marker (the +// │ bar) and the text. Both are the accent, bold — the accent is what makes the +// row read as selected — over the selection background when the theme has a +// usable one. +func cursorStyles() (marker, text lipgloss.Style) { + marker = selectionStyle(lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)) + return marker, marker +} + +// contrastRatio is WCAG relative-luminance contrast, 1 (identical) to 21. +func contrastRatio(a, b color.Color) float64 { + la, lb := relativeLuminance(a), relativeLuminance(b) + if la < lb { + la, lb = lb, la + } + return (la + 0.05) / (lb + 0.05) +} + +func relativeLuminance(c color.Color) float64 { + r, g, b, _ := c.RGBA() + channel := func(v uint32) float64 { + s := float64(v) / 0xffff + if s <= 0.03928 { + return s / 12.92 + } + return math.Pow((s+0.055)/1.055, 2.4) + } + return 0.2126*channel(r) + 0.7152*channel(g) + 0.0722*channel(b) +} + type styles struct { app lipgloss.Style title lipgloss.Style // bold primary for inline titles @@ -55,7 +138,7 @@ func newStyles() styles { return styles{ app: lipgloss.NewStyle().Padding(1, 2), title: lipgloss.NewStyle().Foreground(colorPrimary).Bold(true), - pill: lipgloss.NewStyle().Foreground(lipgloss.Black).Background(colorPrimary).Bold(true).Padding(0, 1), + pill: lipgloss.NewStyle().Foreground(colorOnAccent).Background(colorPrimary).Bold(true).Padding(0, 1), entryFrom: lipgloss.NewStyle().Foreground(colorPrimary).Bold(true), entryDate: styleMuted, entryBody: lipgloss.NewStyle(), diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 000000000..81315c61c --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,297 @@ +package tui + +import ( + "image/color" + "math" + "os" + "path/filepath" + "strings" + + "charm.land/lipgloss/v2" +) + +// Theme is the accent overlay the TUI lays over the terminal's own ANSI palette. +// +// hey-cli deliberately styles with ANSI-16 colors so a terminal retint restyles the +// running TUI for free. A Theme only replaces the handful of semantic colors where a +// desktop theme has a better answer than "bright blue": the accent, the selection +// background, the muted tone, the emphasized foreground and the error color. Anything +// a theme file leaves out keeps its ANSI default. +type Theme struct { + Accent color.Color + Selection color.Color // nil when the theme gives no selection background + Muted color.Color + Bright color.Color + Error color.Color + + // Dark reports whether the theme is for a dark background. HasMode is true when + // a theme file said so; otherwise Dark is a guess the terminal can correct. + Dark bool + HasMode bool + + // Trusted marks a theme the user pointed at explicitly (HEY_THEME). Trusted + // values skip the accent and selection readability gates: the gates exist for + // machine-derived palettes, not for a file chosen by hand. + Trusted bool + + // Source names the file the overlay came from, "" for the ANSI defaults and + // "NO_COLOR" when color is disabled. + Source string +} + +// defaultTheme is the palette the TUI has always used: pure ANSI-16, dark. +func defaultTheme() Theme { + return Theme{ + Accent: lipgloss.BrightBlue, + Muted: lipgloss.BrightBlack, + Bright: lipgloss.BrightWhite, + Error: lipgloss.Red, + Dark: true, + } +} + +// noColorTheme honors NO_COLOR: every color is unset, output is plain text. +func noColorTheme() Theme { + nc := lipgloss.NoColor{} + return Theme{Accent: nc, Muted: nc, Bright: nc, Error: nc, Dark: true, Source: "NO_COLOR"} +} + +// ResolveTheme picks the active theme from the environment, in order: +// +// 1. NO_COLOR set → no color at all +// 2. HEY_THEME → a theme file at that path +// 3. Omarchy's rendered hey.toml in the current theme, when a theme ships or +// templates one +// 4. Omarchy's colors.toml in the current theme +// 5. the ANSI defaults +// +// Only the first file that exists is read; within it, only the keys it provides +// override the defaults. +func ResolveTheme() Theme { + return resolveTheme(os.Getenv, userHomeDir()) +} + +func resolveTheme(getenv func(string) string, home string) Theme { + if getenv("NO_COLOR") != "" { + return noColorTheme() + } + + type candidate struct { + path string + trusted bool + } + candidates := []candidate{} + if path := getenv("HEY_THEME"); path != "" { + candidates = append(candidates, candidate{filepath.Clean(path), true}) + } + if dir := omarchyThemeDir(home); dir != "" { + candidates = append(candidates, + candidate{filepath.Join(dir, "hey.toml"), false}, + candidate{filepath.Join(dir, "colors.toml"), false}) + } + + for _, c := range candidates { + data, err := os.ReadFile(c.path) //nolint:gosec // G304: theme paths come from the user's own environment + if err != nil { + continue + } + theme := overlayTheme(defaultTheme(), parseThemeFile(string(data)), c.trusted) + theme.Source = c.path + theme.Trusted = c.trusted + return theme + } + return defaultTheme() +} + +// omarchyStateDir is where Omarchy keeps the active theme. Omarchy writes it under +// $HOME/.local/state unconditionally, so XDG_STATE_HOME is deliberately ignored. +func omarchyStateDir(home string) string { + if home == "" { + return "" + } + return filepath.Join(home, ".local", "state", "omarchy") +} + +// omarchyThemeDir returns the active Omarchy theme directory, or "" when this +// machine does not run Omarchy. +func omarchyThemeDir(home string) string { + dir := omarchyStateDir(home) + if dir == "" { + return "" + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return "" + } + return filepath.Join(dir, "current", "theme") +} + +// omarchyWatchDir returns the directory to watch for theme switches, or "" when +// there is nothing to watch. omarchy-theme-set swaps the whole theme directory +// with an atomic mv, so the watch has to sit on the parent: a watch inside the +// swapped directory dies with the old inode. +func omarchyWatchDir(home string) string { + dir := omarchyThemeDir(home) + if dir == "" { + return "" + } + return filepath.Dir(dir) +} + +func userHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +// parseThemeFile reads the `key = "value"` lines of a theme file. It understands +// exactly the subset Omarchy's colors.toml uses: one scalar per line, double or +// single quotes, # comments. Anything else is skipped rather than rejected so a +// theme author's extra keys never break the TUI. +func parseThemeFile(data string) map[string]string { + values := make(map[string]string) + for line := range strings.SplitSeq(data, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if idx := unquotedIndex(value, '#'); idx >= 0 { + value = strings.TrimSpace(value[:idx]) + } + value = strings.Trim(value, `"'`) + if key != "" && value != "" { + values[strings.ToLower(key)] = value + } + } + return values +} + +// unquotedIndex returns the index of the first c outside quotes, or -1. +func unquotedIndex(s string, c rune) int { + var quote rune + for i, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + } + case r == '"' || r == '\'': + quote = r + case r == c: + return i + } + } + return -1 +} + +// overlayTheme lays the keys of a parsed theme file over base. The hey.toml keys +// (accent, selection, muted, foreground, error, mode) and the colors.toml spellings +// (red, bright_foreground) share one parser. Emphasis prefers bright_foreground: +// Omarchy's terminal templates map ANSI bright white to it, and plain foreground +// is measurably dimmer on most themes. A trusted file's accent is used as written; +// only machine-derived palettes go through the readability gate. +func overlayTheme(base Theme, values map[string]string, trusted bool) Theme { + theme := base + if c, ok := themeColor(values, "accent"); ok { + theme.Accent = c + if !trusted { + theme.Accent = usableAccent(c, values, base.Accent) + } + } + if c, ok := themeColor(values, "selection"); ok { + theme.Selection = c + } + if c, ok := themeColor(values, "muted"); ok { + theme.Muted = c + } + if c, ok := themeColor(values, "bright_foreground", "foreground"); ok { + theme.Bright = c + } + if c, ok := themeColor(values, "error", "red"); ok { + theme.Error = c + } + switch strings.ToLower(values["mode"]) { + case "dark": + theme.Dark, theme.HasMode = true, true + case "light": + theme.Dark, theme.HasMode = false, true + } + return theme +} + +// A theme's accent is a UI tint, not always a text highlight: kanagawa's is its +// foreground color, osaka-jade's is a deep jade dimmer than anything else on +// screen. usableAccent keeps the accent when it can do the cursor row's job — +// visibly different from the emphasis text, and at least as readable on the +// background as the theme's bright blue — and otherwise falls back to plain ANSI +// bright blue, so the cursor row always matches the palette the rest of the text +// is rendered in. That matters in foot, where a running window keeps the palette +// it opened with: painting the new theme's hex there would mix palettes, while +// ANSI stays self-consistent. The theme's bright_blue hex is used only to judge +// the accent, never painted. +const ( + minAccentDistance = 64.0 // sRGB distance from the emphasis text + minAccentContrast = 5.0 // contrast against the background that wins outright +) + +func usableAccent(accent color.Color, values map[string]string, ansiBlue color.Color) color.Color { + if text, ok := themeColor(values, "bright_foreground", "foreground"); ok && colorDistance(accent, text) < minAccentDistance { + return ansiBlue + } + background, ok := themeColor(values, "background") + if !ok { + return accent + } + estimate, ok := themeColor(values, "bright_blue", "blue") + if !ok { + estimate = ansiBlue + } + if ratio := contrastRatio(accent, background); ratio >= minAccentContrast || ratio >= contrastRatio(estimate, background) { + return accent + } + return ansiBlue +} + +// colorDistance is the Euclidean distance between two colors in 8-bit sRGB. +func colorDistance(a, b color.Color) float64 { + ar, ag, ab, _ := a.RGBA() + br, bg, bb, _ := b.RGBA() + d := func(x, y uint32) float64 { return float64(x>>8) - float64(y>>8) } + return math.Sqrt(d(ar, br)*d(ar, br) + d(ag, bg)*d(ag, bg) + d(ab, bb)*d(ab, bb)) +} + +// themeColor returns the first key that holds a valid hex color. +func themeColor(values map[string]string, keys ...string) (color.Color, bool) { + for _, key := range keys { + if hex, ok := values[key]; ok && isHexColor(hex) { + return lipgloss.Color(hex), true + } + } + return nil, false +} + +func isHexColor(s string) bool { + if !strings.HasPrefix(s, "#") { + return false + } + digits := s[1:] + if len(digits) != 3 && len(digits) != 6 { + return false + } + for _, r := range digits { + isDigit := r >= '0' && r <= '9' + isLower := r >= 'a' && r <= 'f' + isUpper := r >= 'A' && r <= 'F' + if !isDigit && !isLower && !isUpper { + return false + } + } + return true +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 000000000..5d19fa066 --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,450 @@ +package tui + +import ( + "image/color" + "os" + "path/filepath" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// TestMain isolates HOME: newModel resolves the live Omarchy theme from it, and +// the rendering tests assert exact ANSI output, so a developer's active theme +// must never leak into them. Tests that want an Omarchy theme build one with +// omarchyHome and point HOME at it themselves. +func TestMain(m *testing.M) { + home, err := os.MkdirTemp("", "hey-tui-test-home-") + if err == nil { + os.Setenv("HOME", home) + } + os.Unsetenv("HEY_THEME") + os.Unsetenv("NO_COLOR") + code := m.Run() + if err == nil { + os.RemoveAll(home) + } + os.Exit(code) +} + +func envOf(values map[string]string) func(string) string { + return func(key string) string { return values[key] } +} + +// omarchyHome builds a fake $HOME with an Omarchy state dir holding the given +// theme files and returns the home path. +func omarchyHome(t *testing.T, files map[string]string) string { + t.Helper() + home := t.TempDir() + themeDir := filepath.Join(home, ".local", "state", "omarchy", "current", "theme") + if err := os.MkdirAll(themeDir, 0o755); err != nil { + t.Fatal(err) + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(themeDir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return home +} + +const tokyoNightColors = `mode = "dark" + +accent = "#7aa2f7" +selection = "#292e42" +muted = "#414868" + +background = "#1a1b26" +foreground = "#a9b1d6" +bright_foreground = "#c0caf5" + +red = "#f7768e" +blue = "#7aa2f7" +` + +func TestParseThemeFile(t *testing.T) { + values := parseThemeFile(` +# a comment +mode = "light" +accent = '#ABCDEF' # trailing comment +selection="#123" +[section] +odd line without equals +empty = +not_hex = "blue" +`) + want := map[string]string{"mode": "light", "accent": "#ABCDEF", "selection": "#123", "not_hex": "blue"} + if len(values) != len(want) { + t.Fatalf("parsed %v, want %v", values, want) + } + for k, v := range want { + if values[k] != v { + t.Errorf("%s = %q, want %q", k, values[k], v) + } + } +} + +func TestOverlayThemeKeepsDefaultsForMissingKeys(t *testing.T) { + theme := overlayTheme(defaultTheme(), map[string]string{"accent": "#7aa2f7", "not_hex": "blue", "error": "nope"}, false) + if theme.Accent != lipgloss.Color("#7aa2f7") { + t.Errorf("accent not overlaid: %v", theme.Accent) + } + if theme.Muted != lipgloss.BrightBlack || theme.Error != lipgloss.Red || theme.Bright != lipgloss.BrightWhite { + t.Errorf("defaults not kept: %+v", theme) + } + if theme.Selection != nil { + t.Errorf("selection should stay unset, got %v", theme.Selection) + } + if theme.HasMode || !theme.Dark { + t.Errorf("mode should be the dark default without HasMode, got dark=%v hasMode=%v", theme.Dark, theme.HasMode) + } +} + +func TestOverlayThemeColorsToml(t *testing.T) { + theme := overlayTheme(defaultTheme(), parseThemeFile(tokyoNightColors), false) + if theme.Accent != lipgloss.Color("#7aa2f7") || theme.Selection != lipgloss.Color("#292e42") || theme.Muted != lipgloss.Color("#414868") { + t.Errorf("accent/selection/muted wrong: %+v", theme) + } + if theme.Error != lipgloss.Color("#f7768e") { + t.Errorf("error should map from red, got %v", theme.Error) + } + if theme.Bright != lipgloss.Color("#c0caf5") { + t.Errorf("bright should map from bright_foreground, got %v", theme.Bright) + } + if !theme.Dark || !theme.HasMode { + t.Errorf("mode = dark should set Dark and HasMode: %+v", theme) + } +} + +func TestOverlayThemeHeyTomlKeysWin(t *testing.T) { + theme := overlayTheme(defaultTheme(), map[string]string{"error": "#111111", "red": "#222222", "mode": "light"}, false) + if theme.Error != lipgloss.Color("#111111") { + t.Errorf("error should beat red, got %v", theme.Error) + } + if theme.Dark || !theme.HasMode { + t.Errorf("mode = light should clear Dark: %+v", theme) + } +} + +func TestResolveThemeNoColor(t *testing.T) { + home := omarchyHome(t, map[string]string{"colors.toml": tokyoNightColors}) + theme := resolveTheme(envOf(map[string]string{"NO_COLOR": "1"}), home) + if theme.Source != "NO_COLOR" { + t.Fatalf("NO_COLOR should win over omarchy, got source %q", theme.Source) + } + if _, ok := theme.Accent.(lipgloss.NoColor); !ok { + t.Errorf("accent should be NoColor, got %v", theme.Accent) + } +} + +func TestResolveThemeOrder(t *testing.T) { + home := omarchyHome(t, map[string]string{ + "colors.toml": tokyoNightColors, + "hey.toml": "accent = \"#ff0000\"\n", + }) + custom := filepath.Join(t.TempDir(), "custom.toml") + if err := os.WriteFile(custom, []byte("accent = \"#00ff00\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + theme := resolveTheme(envOf(map[string]string{"HEY_THEME": custom}), home) + if theme.Accent != lipgloss.Color("#00ff00") || theme.Source != custom { + t.Errorf("HEY_THEME should win: %+v", theme) + } + + theme = resolveTheme(envOf(map[string]string{"HEY_THEME": "/nonexistent/theme.toml"}), home) + if theme.Accent != lipgloss.Color("#ff0000") { + t.Errorf("missing HEY_THEME should fall through to hey.toml: %+v", theme) + } + if theme.Muted != lipgloss.BrightBlack { + t.Errorf("hey.toml alone should not pull keys from colors.toml: %+v", theme) + } + + if err := os.Remove(filepath.Join(home, ".local", "state", "omarchy", "current", "theme", "hey.toml")); err != nil { + t.Fatal(err) + } + theme = resolveTheme(envOf(nil), home) + if theme.Accent != lipgloss.Color("#7aa2f7") { + t.Errorf("without hey.toml colors.toml should be read: %+v", theme) + } +} + +func TestResolveThemeWithoutOmarchy(t *testing.T) { + theme := resolveTheme(envOf(nil), t.TempDir()) + if theme != defaultTheme() { + t.Errorf("non-omarchy machine should get the ANSI defaults, got %+v", theme) + } + if omarchyWatchDir(t.TempDir()) != "" { + t.Error("nothing to watch without an omarchy state dir") + } + if watchThemeCmd("") != nil { + t.Error("watch command should be nil when there is no directory") + } +} + +func TestOmarchyWatchDirIsThemeParent(t *testing.T) { + home := omarchyHome(t, nil) + want := filepath.Join(home, ".local", "state", "omarchy", "current") + if got := omarchyWatchDir(home); got != want { + t.Errorf("watch dir = %q, want %q", got, want) + } +} + +func TestWatchThemeCmdSeesWritesInsideThemeDir(t *testing.T) { + // omarchy-theme-refresh re-renders files in place instead of swapping the + // directory, so a write inside theme/ has to wake the watcher too. + home := omarchyHome(t, map[string]string{"colors.toml": tokyoNightColors}) + done := make(chan tea.Msg, 1) + go func() { done <- watchThemeCmd(omarchyWatchDir(home))() }() + + deadline := time.After(5 * time.Second) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + hey := filepath.Join(home, ".local", "state", "omarchy", "current", "theme", "hey.toml") + for { + select { + case msg := <-done: + if _, ok := msg.(themeChangedMsg); !ok { + t.Fatalf("want themeChangedMsg, got %T", msg) + } + return + case <-deadline: + t.Fatal("write inside theme/ never woke the watcher") + case <-tick.C: + // Re-write until the watcher observes it: arming races the first write. + if err := os.WriteFile(hey, []byte("accent = \"#ff0000\"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + } +} + +func TestApplyThemeLightModeFlipsBright(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + + light := defaultTheme() + light.Dark = false + applyTheme(light) + if colorBright != lipgloss.Black { + t.Errorf("light ANSI theme should use black for emphasis, got %v", colorBright) + } + + themed := overlayTheme(defaultTheme(), map[string]string{"foreground": "#333333", "mode": "light"}, false) + applyTheme(themed) + if colorBright != lipgloss.Color("#333333") { + t.Errorf("a themed foreground should be kept in light mode, got %v", colorBright) + } + if selectionStyle(lipgloss.NewStyle()).GetBackground() != lipgloss.NewStyle().GetBackground() { + t.Error("no selection color should leave the style untouched") + } +} + +func TestUsableAccentFallsBackToBrightBlue(t *testing.T) { + cases := []struct { + name string + values map[string]string + want string + }{ + {"hackerman keeps a vivid accent", map[string]string{ + "accent": "#82FB9C", "background": "#0B0C16", "bright_foreground": "#f2f2f2", "bright_blue": "#c4d2ed"}, "#82FB9C"}, + {"kanagawa's accent is its foreground", map[string]string{ + "accent": "#dcd7ba", "background": "#1f1f28", "bright_foreground": "#dcd7ba", "bright_blue": "#7fb4ca"}, ""}, + {"osaka-jade's accent is dimmer than its blue", map[string]string{ + "accent": "#509475", "background": "#111c18", "bright_foreground": "#e1f0ea", "bright_blue": "#ACD4CF"}, ""}, + {"lupine's accent beats its even dimmer blue", map[string]string{ + "accent": "#3264eb", "background": "#fafafa", "bright_foreground": "#1a1a1a", "bright_blue": "#5482ff"}, "#3264eb"}, + {"a bare hey.toml accent is trusted", map[string]string{"accent": "#ff00ff"}, "#ff00ff"}, + } + for _, tc := range cases { + theme := overlayTheme(defaultTheme(), tc.values, false) + want := color.Color(lipgloss.BrightBlue) // "" = fall back to the terminal's own bright blue + if tc.want != "" { + want = lipgloss.Color(tc.want) + } + if theme.Accent != want { + t.Errorf("%s: accent = %v, want %v", tc.name, theme.Accent, want) + } + } + + noBlue := overlayTheme(defaultTheme(), map[string]string{"accent": "#dcd7ba", "bright_foreground": "#dcd7ba"}, false) + if noBlue.Accent != lipgloss.BrightBlue { + t.Errorf("without a theme blue the fallback is ANSI bright blue, got %v", noBlue.Accent) + } +} + +func TestCursorStylesCarryTheSelectionBackground(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + applyTheme(overlayTheme(defaultTheme(), map[string]string{"accent": "#82FB9C", "selection": "#1f253a"}, false)) + + marker, text := cursorStyles() + gap := selectionStyle(lipgloss.NewStyle()) + want := lipgloss.Color("#1f253a") + if marker.GetBackground() != want || text.GetBackground() != want || gap.GetBackground() != want { + t.Error("every cursor-row segment a section renders through the helpers must carry the selection background") + } + if renderScreenerRows(&screenerPane{rows: []screenerRow{{detail: "maria@example.com"}}}, 5, 40) == "" { + t.Error("screener rows should render") + } +} + +func TestPillTextReadsOnTheAccent(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + + applyTheme(defaultTheme()) + if colorOnAccent != color.Color(lipgloss.Black) { + t.Errorf("the ANSI accent keeps the classic black pill text, got %v", colorOnAccent) + } + + // lupine's dark blue carries black at ~3.7:1; white reads better. + lupine := map[string]string{"accent": "#3264eb", "background": "#fafafa", + "bright_foreground": "#1a1a1a", "bright_blue": "#5482ff"} + applyTheme(overlayTheme(defaultTheme(), lupine, false)) + if colorOnAccent != color.Color(lipgloss.BrightWhite) { + t.Errorf("a dark themed accent needs bright white pill text, got %v", colorOnAccent) + } + + // hackerman's bright green still reads best with black on it. + applyTheme(overlayTheme(defaultTheme(), map[string]string{"accent": "#82FB9C"}, false)) + if colorOnAccent != color.Color(lipgloss.Black) { + t.Errorf("a bright themed accent keeps black pill text, got %v", colorOnAccent) + } + + applyTheme(noColorTheme()) + if _, ok := colorOnAccent.(lipgloss.NoColor); !ok { + t.Errorf("NO_COLOR must keep the pill colorless, got %v", colorOnAccent) + } +} + +func TestTrustedThemeBypassesTheGates(t *testing.T) { + // kanagawa's foreground-as-accent would be gated for a machine palette, but a + // hand-chosen HEY_THEME file means it exactly as written. + values := map[string]string{"accent": "#dcd7ba", "background": "#1f1f28", "bright_foreground": "#dcd7ba"} + if theme := overlayTheme(defaultTheme(), values, true); theme.Accent != lipgloss.Color("#dcd7ba") { + t.Errorf("a trusted accent must be used as written, got %v", theme.Accent) + } + + t.Cleanup(func() { applyTheme(defaultTheme()) }) + // rose-pine's 2.5:1 accent-on-selection pair is dropped for machine palettes. + trusted := overlayTheme(defaultTheme(), map[string]string{"accent": "#56949f", "selection": "#dfdad9"}, true) + trusted.Trusted = true + applyTheme(trusted) + if colorSelection == nil { + t.Error("a trusted selection must be kept even below the contrast gate") + } + + home := omarchyHome(t, nil) + custom := filepath.Join(home, "mine.toml") + if err := os.WriteFile(custom, []byte(`accent = "#dcd7ba"`+"\n"+`bright_foreground = "#dcd7ba"`), 0o644); err != nil { + t.Fatal(err) + } + theme := resolveTheme(envOf(map[string]string{"HEY_THEME": custom}), home) + if !theme.Trusted || theme.Accent != lipgloss.Color("#dcd7ba") { + t.Errorf("HEY_THEME must resolve as trusted with the accent as written, got %+v", theme) + } +} + +func TestSelectionBackgroundNeedsContrastWithAccent(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + + // hackerman: green accent on a near-black selection, 11.7:1 + applyTheme(overlayTheme(defaultTheme(), map[string]string{"accent": "#82FB9C", "selection": "#1f253a"}, false)) + if colorSelection == nil { + t.Error("a high-contrast selection should be kept") + } + + // rose-pine: teal accent on a warm light selection, 2.5:1 — would be mud + applyTheme(overlayTheme(defaultTheme(), map[string]string{"accent": "#56949f", "selection": "#dfdad9"}, false)) + if colorSelection != nil { + t.Error("a selection the accent cannot be read on should be dropped") + } + marker, text := cursorStyles() + if marker.GetForeground() != lipgloss.Color("#56949f") || text.GetForeground() != lipgloss.Color("#56949f") { + t.Error("the cursor row must stay accent-colored without a selection background") + } + + if ratio := contrastRatio(lipgloss.Color("#000000"), lipgloss.Color("#ffffff")); ratio < 20.9 || ratio > 21.1 { + t.Errorf("black/white contrast = %.2f, want 21", ratio) + } +} + +func TestRestyleRefreshesEveryCopy(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + m := modelWithBoxes() + + theme := overlayTheme(defaultTheme(), map[string]string{"accent": "#ff8800", "selection": "#222233"}, false) + m.restyle(theme) + + want := lipgloss.Color("#ff8800") + if m.styles.title.GetForeground() != want { + t.Errorf("model styles not rebuilt: %v", m.styles.title.GetForeground()) + } + if m.vc.styles.title.GetForeground() != want { + t.Errorf("view context styles not rebuilt: %v", m.vc.styles.title.GetForeground()) + } + if m.help.styles.title.GetForeground() != want { + t.Errorf("help bar styles not rebuilt: %v", m.help.styles.title.GetForeground()) + } + if selectionStyle(lipgloss.NewStyle()).GetBackground() != lipgloss.Color("#222233") { + t.Error("selection background not applied") + } + if view := m.mailView.View(); view == "" { + t.Error("mail view should still render after a restyle") + } +} + +func TestBackgroundColorMsgOnlyWhenThemeHasNoMode(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + m := sizedModel() + m.theme = defaultTheme() + + updated, _ := m.Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#ffffff")}) + m = updated.(model) + if m.theme.Dark { + t.Error("a light terminal background should flip Dark when the theme has no mode") + } + + m.theme.HasMode, m.theme.Dark = true, true + updated, _ = m.Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#ffffff")}) + m = updated.(model) + if !m.theme.Dark { + t.Error("a theme that states its mode must not be overridden by the terminal") + } +} + +func TestThemeChangedMsgKeepsPaletteMidSwap(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + home := omarchyHome(t, nil) + t.Setenv("HOME", home) + if err := os.RemoveAll(filepath.Join(home, ".local", "state", "omarchy", "current", "theme")); err != nil { + t.Fatal(err) + } + + m := sizedModel() + before := overlayTheme(defaultTheme(), map[string]string{"accent": "#ff8800"}, false) + m.restyle(before) + + updated, cmd := m.Update(themeChangedMsg{}) + m = updated.(model) + if m.theme.Accent != lipgloss.Color("#ff8800") { + t.Errorf("theme dir missing mid-swap should keep the palette, got %v", m.theme.Accent) + } + if cmd == nil { + t.Error("watcher should be re-armed") + } +} + +func TestThemeChangedMsgReloadsTheme(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + home := omarchyHome(t, map[string]string{"colors.toml": tokyoNightColors}) + t.Setenv("HOME", home) + + m := sizedModel() + m.restyle(defaultTheme()) + updated, _ := m.Update(themeChangedMsg{}) + m = updated.(model) + if m.theme.Accent != lipgloss.Color("#7aa2f7") { + t.Errorf("theme change should re-read colors.toml, got %v", m.theme.Accent) + } +} diff --git a/internal/tui/theme_watch.go b/internal/tui/theme_watch.go new file mode 100644 index 000000000..454e6101e --- /dev/null +++ b/internal/tui/theme_watch.go @@ -0,0 +1,56 @@ +package tui + +import ( + "path/filepath" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/fsnotify/fsnotify" +) + +// themeChangedMsg reports that the Omarchy theme directory changed on disk. +type themeChangedMsg struct{} + +// themeSettleDelay lets omarchy-theme-set finish its rm/mv swap and the template +// renders that follow before the theme is re-read, so one switch costs one restyle. +const themeSettleDelay = 250 * time.Millisecond + +// watchThemeCmd blocks until something changes in dir, then reports it once. The +// handler re-arms it; a fresh watcher per cycle means a directory that was swapped +// out from under us is simply watched again by name. The theme directory inside is +// watched too when it exists: omarchy-theme-set renders templates before its +// atomic swap of the whole directory, but omarchy-theme-refresh re-renders files +// in place, which the non-recursive parent watch would never see. Returns nil +// when there is nothing to watch. +func watchThemeCmd(dir string) tea.Cmd { + if dir == "" { + return nil + } + return func() tea.Msg { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil + } + defer watcher.Close() + if err := watcher.Add(dir); err != nil { + return nil + } + _ = watcher.Add(filepath.Join(dir, "theme")) // absent mid-swap; the parent event re-arms us + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return nil + } + if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) { + time.Sleep(themeSettleDelay) + return themeChangedMsg{} + } + case _, ok := <-watcher.Errors: + if !ok { + return nil + } + } + } + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 911bf0ff3..8c0b26e69 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -34,6 +34,7 @@ type model struct { vc *viewContext rootSDK *hey.Client cancel context.CancelFunc + theme Theme styles styles help helpBar @@ -76,6 +77,8 @@ func newModel() model { } func newModelWithMailAccounts(rootSDK, sdk *hey.Client, selected string) model { + theme := ResolveTheme() + applyTheme(theme) s := newStyles() ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel stored, called on ctrl+c vc := newViewContext(ctx, rootSDK, sdk, s) @@ -93,6 +96,7 @@ func newModelWithMailAccounts(rootSDK, sdk *hey.Client, selected string) model { vc: vc, rootSDK: rootSDK, cancel: cancel, + theme: theme, styles: s, help: newHelpBar(s), section: sectionMail, @@ -133,9 +137,26 @@ func (m model) Init() tea.Cmd { m.stampViewCmd(m.activeView.Init()), loadMailAccounts(m.vc.ctx, m.rootSDK, strconv.FormatInt(m.mailAccount.id, 10)), spinnerTick(), + tea.RequestBackgroundColor, + watchThemeCmd(omarchyWatchDir(userHomeDir())), ) } +// restyle makes theme the active palette and refreshes every copy of the styles: +// the model's, the view context's, the help bar's, and whatever the sections cached. +func (m *model) restyle(theme Theme) { + m.theme = theme + applyTheme(theme) + m.styles = newStyles() + m.vc.styles = m.styles + m.help.setStyles(m.styles) + for _, view := range []sectionView{m.mailView, m.contactsView, m.calendarView, m.journalView} { + if view != nil { + view.Restyle() + } + } +} + // --- Update --- func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -204,6 +225,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateHelpBindings() return m, nil + case tea.BackgroundColorMsg: + // The terminal knows its background; a theme file that states a mode knows better. + if !m.theme.HasMode && m.theme.Dark != msg.IsDark() { + theme := m.theme + theme.Dark = msg.IsDark() + m.restyle(theme) + } + return m, nil + + case themeChangedMsg: + // omarchy-theme-set removes the theme directory before moving the new one in. + // Mid-swap there is nothing to read yet; keep the current palette and wait + // for the move. + if theme := ResolveTheme(); theme.Source != "" || omarchyThemeDir(userHomeDir()) == "" { + if !theme.HasMode { + // Carry the last known mode for now and ask the terminal again: + // the switch usually retints the terminal too, and the + // BackgroundColorMsg reply corrects a mode-less theme. + theme.Dark = m.theme.Dark + } + m.restyle(theme) + } + return m, tea.Batch(watchThemeCmd(omarchyWatchDir(userHomeDir())), tea.RequestBackgroundColor) + case screenerClosedMsg: return m.closeScreener() diff --git a/nix/package.nix b/nix/package.nix index 80c11153b..0e43ec29a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-W6x5SxIlVlAun4NIK5zogpIhq5uufXw/+SsRd4ddHgc="; + vendorHash = "sha256-YFm6/FVI2uTdTwPhJO195oBlOY2sNS2ukeFSC2/G4WY="; subPackages = [ "cmd/hey" ];