From a96c1a52f45e539934f133258b88531565081eee Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 20:13:21 -0400 Subject: [PATCH] feat(tui): add 13 preloaded themes matching web design system Port all web CSS token themes to TUI via shared vocabulary (accent, dim, muted, ok, todo, event, remind, danger). Styles rebuild from active theme on switch. Press T to cycle, persists to ~/.nib/theme. Glamour markdown renderer respects light/dark per theme. --- internal/tui/detail.go | 2 +- internal/tui/help.go | 1 + internal/tui/keys.go | 2 + internal/tui/model.go | 6 + internal/tui/stumble.go | 2 +- internal/tui/styles.go | 236 ++++++++++++++++------------------------ internal/tui/tagrail.go | 3 +- internal/tui/theme.go | 90 +++++++++++++++ 8 files changed, 196 insertions(+), 146 deletions(-) create mode 100644 internal/tui/theme.go diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 2130ddd..80a2d55 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -115,7 +115,7 @@ func (d detailModel) previewView(width int) string { bodyWidth = 20 } r, _ := glamour.NewTermRenderer( - glamour.WithStylePath("dark"), + glamour.WithStylePath(glamourStyle()), glamour.WithWordWrap(bodyWidth), ) rendered, err := r.Render(e.Body) diff --git a/internal/tui/help.go b/internal/tui/help.go index ef3bd18..a5e3958 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -33,6 +33,7 @@ func renderHelp(width, height int) string { {"2", "cards view"}, {"s", "cycle sort (cards)"}, {"i", "cycle intent (cards)"}, + {"T", "cycle theme"}, }}, {"Actions", [][2]string{ {"d", "delete (with confirm)"}, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index e68b7be..a0d1723 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -34,6 +34,7 @@ type keyMap struct { Tab key.Binding ToggleRail key.Binding Stumble key.Binding + Theme key.Binding } var keys = keyMap{ @@ -68,4 +69,5 @@ var keys = keyMap{ Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "focus cycle")), ToggleRail: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle tag rail")), Stumble: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "stumble")), + Theme: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "theme")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index 5e51c8e..abdf619 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -105,6 +105,8 @@ type model struct { } func newModel(store *db.Store) model { + loadTheme() + applyTheme() inp := newInputModel() inp.ti.Focus() return model{ @@ -565,6 +567,10 @@ func (m model) updateBrowse(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "T": + t := cycleTheme() + return m, m.setStatus("theme: " + t.Name) + case "i": if m.mode == modeCards && m.state == stateList { m.cards.setIntent(m.cards.intent.next()) diff --git a/internal/tui/stumble.go b/internal/tui/stumble.go index e35eaf4..d897bdb 100644 --- a/internal/tui/stumble.go +++ b/internal/tui/stumble.go @@ -111,7 +111,7 @@ func (s stumbleModel) view() string { bodyWidth = 20 } r, _ := glamour.NewTermRenderer( - glamour.WithStylePath("dark"), + glamour.WithStylePath(glamourStyle()), glamour.WithWordWrap(bodyWidth), ) rendered, err := r.Render(e.Body) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 637e0f5..f744ca2 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -3,147 +3,97 @@ package tui import "github.com/charmbracelet/lipgloss" var ( - subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} - highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} - dim = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} - - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(highlight). - PaddingLeft(1) - - statusStyle = lipgloss.NewStyle(). - Foreground(dim). - PaddingLeft(1) - - listItemStyle = lipgloss.NewStyle(). - PaddingLeft(4) - - selectedItemStyle = lipgloss.NewStyle(). - PaddingLeft(1). - Bold(true). - Foreground(highlight). - SetString("›") - - glyphStyle = lipgloss.NewStyle(). - Width(2) - - completedGlyphStyle = lipgloss.NewStyle(). - Width(2). - Foreground(dim) - - tagStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) - - idStyle = lipgloss.NewStyle(). - Foreground(dim) - - inputPromptStyle = lipgloss.NewStyle(). - Foreground(highlight). - Bold(true) - - detailHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(highlight). - MarginBottom(1) - - detailBodyStyle = lipgloss.NewStyle(). - PaddingLeft(2). - PaddingTop(1) - - helpStyle = lipgloss.NewStyle(). - Foreground(dim). - PaddingLeft(1) - - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF0000")). - PaddingLeft(1) - - dateHeaderStyle = lipgloss.NewStyle(). - Foreground(dim). - PaddingLeft(1) - - pinnedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#D4A017", Dark: "#FFD700"}) - - filterPillStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}). - Bold(true) - - helpKeyStyle = lipgloss.NewStyle(). - Foreground(highlight). - Bold(true). - Width(18) - - helpDescStyle = lipgloss.NewStyle(). - Foreground(dim) - - affordanceStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}). - Bold(true) - - useCountStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"}) - - modeStyle = lipgloss.NewStyle(). - Foreground(dim). - Bold(true) - - detailLabelStyle = lipgloss.NewStyle(). - Foreground(highlight). - Bold(true) - - detailValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"}) - - checkDoneStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) - - checkPendingStyle = lipgloss.NewStyle(). - Foreground(dim) - - searchPillStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#E06C75", Dark: "#E06C75"}). - Bold(true) - - gutterStyle = lipgloss.NewStyle(). - Foreground(dim) - - drawerBorderStyle = lipgloss.NewStyle(). - Foreground(dim) - - drawerHintsStyle = lipgloss.NewStyle(). - Foreground(dim). - PaddingLeft(2) - - drawerPreviewStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#AAAAAA"}). - PaddingLeft(2) - - separatorStyle = lipgloss.NewStyle(). - Foreground(dim) - - hintKeyStyle = lipgloss.NewStyle(). - Foreground(highlight). - Bold(true) - - hintDescStyle = lipgloss.NewStyle(). - Foreground(dim) - - railHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(dim) - - railTagStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) - - railActiveTagStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}). - Bold(true) - - railCountStyle = lipgloss.NewStyle(). - Foreground(dim) - - stumbleAgeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#cc4400", Dark: "#fab387"}) + titleStyle lipgloss.Style + statusStyle lipgloss.Style + listItemStyle lipgloss.Style + selectedItemStyle lipgloss.Style + glyphStyle lipgloss.Style + completedGlyphStyle lipgloss.Style + tagStyle lipgloss.Style + idStyle lipgloss.Style + inputPromptStyle lipgloss.Style + detailHeaderStyle lipgloss.Style + detailBodyStyle lipgloss.Style + helpStyle lipgloss.Style + errorStyle lipgloss.Style + dateHeaderStyle lipgloss.Style + pinnedStyle lipgloss.Style + filterPillStyle lipgloss.Style + helpKeyStyle lipgloss.Style + helpDescStyle lipgloss.Style + affordanceStyle lipgloss.Style + useCountStyle lipgloss.Style + modeStyle lipgloss.Style + detailLabelStyle lipgloss.Style + detailValueStyle lipgloss.Style + checkDoneStyle lipgloss.Style + checkPendingStyle lipgloss.Style + searchPillStyle lipgloss.Style + gutterStyle lipgloss.Style + drawerBorderStyle lipgloss.Style + drawerHintsStyle lipgloss.Style + drawerPreviewStyle lipgloss.Style + separatorStyle lipgloss.Style + hintKeyStyle lipgloss.Style + hintDescStyle lipgloss.Style + railHeaderStyle lipgloss.Style + railTagStyle lipgloss.Style + railActiveTagStyle lipgloss.Style + railCountStyle lipgloss.Style + stumbleAgeStyle lipgloss.Style ) + +func init() { + applyTheme() +} + +func applyTheme() { + t := activeTheme() + accent := lipgloss.Color(t.Accent) + dim := lipgloss.Color(t.Dim) + muted := lipgloss.Color(t.Muted) + ok := lipgloss.Color(t.Ok) + todo := lipgloss.Color(t.Todo) + event := lipgloss.Color(t.Event) + remind := lipgloss.Color(t.Remind) + danger := lipgloss.Color(t.Danger) + + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).PaddingLeft(1) + statusStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1) + listItemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(1).Bold(true).Foreground(accent).SetString("›") + glyphStyle = lipgloss.NewStyle().Width(2) + completedGlyphStyle = lipgloss.NewStyle().Width(2).Foreground(dim) + tagStyle = lipgloss.NewStyle().Foreground(ok) + idStyle = lipgloss.NewStyle().Foreground(dim) + inputPromptStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + detailHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(accent).MarginBottom(1) + detailBodyStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(1) + helpStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1) + errorStyle = lipgloss.NewStyle().Foreground(danger).PaddingLeft(1) + dateHeaderStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(1) + pinnedStyle = lipgloss.NewStyle().Foreground(todo) + filterPillStyle = lipgloss.NewStyle().Foreground(ok).Bold(true) + helpKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true).Width(18) + helpDescStyle = lipgloss.NewStyle().Foreground(dim) + affordanceStyle = lipgloss.NewStyle().Foreground(event).Bold(true) + useCountStyle = lipgloss.NewStyle().Foreground(remind) + modeStyle = lipgloss.NewStyle().Foreground(dim).Bold(true) + detailLabelStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + detailValueStyle = lipgloss.NewStyle().Foreground(muted) + checkDoneStyle = lipgloss.NewStyle().Foreground(ok) + checkPendingStyle = lipgloss.NewStyle().Foreground(dim) + searchPillStyle = lipgloss.NewStyle().Foreground(danger).Bold(true) + gutterStyle = lipgloss.NewStyle().Foreground(dim) + drawerBorderStyle = lipgloss.NewStyle().Foreground(dim) + drawerHintsStyle = lipgloss.NewStyle().Foreground(dim).PaddingLeft(2) + drawerPreviewStyle = lipgloss.NewStyle().Foreground(muted).PaddingLeft(2) + separatorStyle = lipgloss.NewStyle().Foreground(dim) + hintKeyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + hintDescStyle = lipgloss.NewStyle().Foreground(dim) + railHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(dim) + railTagStyle = lipgloss.NewStyle().Foreground(ok) + railActiveTagStyle = lipgloss.NewStyle().Foreground(ok).Bold(true) + railCountStyle = lipgloss.NewStyle().Foreground(dim) + stumbleAgeStyle = lipgloss.NewStyle().Foreground(remind) +} diff --git a/internal/tui/tagrail.go b/internal/tui/tagrail.go index 3ddbe80..ac5097b 100644 --- a/internal/tui/tagrail.go +++ b/internal/tui/tagrail.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/charmbracelet/lipgloss" "github.com/lerko/nib/internal/db" ) @@ -78,7 +79,7 @@ func (r tagRailModel) view(focused bool) string { headerStyle := railHeaderStyle if focused { - headerStyle = headerStyle.Foreground(highlight) + headerStyle = headerStyle.Foreground(lipgloss.Color(activeTheme().Accent)) } b.WriteString(headerStyle.Render("tags")) b.WriteString("\n") diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..6c24cd1 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,90 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" +) + +type Theme struct { + Name string + Dark bool + Accent string + Dim string + Muted string + Ok string + Todo string + Event string + Remind string + Danger string +} + +var themes = []Theme{ + {Name: "dark", Dark: true, Accent: "#c8942a", Dim: "#504840", Muted: "#8c8070", Ok: "#7aab72", Todo: "#d4a84b", Event: "#6898c8", Remind: "#c8784a", Danger: "#b85858"}, + {Name: "tinycard", Dark: true, Accent: "#ad8ee6", Dim: "#555a6a", Muted: "#8b90a0", Ok: "#4ade80", Todo: "#fbbf24", Event: "#22d3ee", Remind: "#e8845a", Danger: "#ef4444"}, + {Name: "catppuccin", Dark: true, Accent: "#cba6f7", Dim: "#6c7086", Muted: "#a6adc8", Ok: "#a6e3a1", Todo: "#f9e2af", Event: "#89b4fa", Remind: "#fab387", Danger: "#f38ba8"}, + {Name: "nord", Dark: true, Accent: "#88c0d0", Dim: "#4c566a", Muted: "#d8dee9", Ok: "#a3be8c", Todo: "#ebcb8b", Event: "#81a1c1", Remind: "#d08770", Danger: "#bf616a"}, + {Name: "dracula", Dark: true, Accent: "#bd93f9", Dim: "#6272a4", Muted: "#bfbfbf", Ok: "#50fa7b", Todo: "#f1fa8c", Event: "#8be9fd", Remind: "#ffb86c", Danger: "#ff5555"}, + {Name: "gruvbox", Dark: true, Accent: "#fabd2f", Dim: "#665c54", Muted: "#a89984", Ok: "#b8bb26", Todo: "#fabd2f", Event: "#83a598", Remind: "#fe8019", Danger: "#fb4934"}, + {Name: "rosepine", Dark: true, Accent: "#c4a7e7", Dim: "#6e6a86", Muted: "#908caa", Ok: "#a6da95", Todo: "#f6c177", Event: "#31748f", Remind: "#ea9a97", Danger: "#eb6f92"}, + {Name: "tokyonight", Dark: true, Accent: "#7aa2f7", Dim: "#565f89", Muted: "#a9b1d6", Ok: "#9ece6a", Todo: "#e0af68", Event: "#7aa2f7", Remind: "#ff9e64", Danger: "#f7768e"}, + {Name: "solarized", Dark: true, Accent: "#268bd2", Dim: "#586e75", Muted: "#657b83", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"}, + {Name: "paper", Dark: false, Accent: "#8a6018", Dim: "#a09080", Muted: "#6a5e50", Ok: "#2a6828", Todo: "#7a5c00", Event: "#245890", Remind: "#984020", Danger: "#882030"}, + {Name: "catppuccin-latte", Dark: false, Accent: "#8839ef", Dim: "#9ca0b0", Muted: "#6c6f85", Ok: "#40a02b", Todo: "#df8e1d", Event: "#1e66f5", Remind: "#fe640b", Danger: "#d20f39"}, + {Name: "rosepine-dawn", Dark: false, Accent: "#907aa9", Dim: "#9893a5", Muted: "#797593", Ok: "#56949f", Todo: "#ea9d34", Event: "#286983", Remind: "#d7827e", Danger: "#b4637a"}, + {Name: "solarized-light", Dark: false, Accent: "#268bd2", Dim: "#93a1a1", Muted: "#586e75", Ok: "#859900", Todo: "#b58900", Event: "#268bd2", Remind: "#cb4b16", Danger: "#dc322f"}, +} + +var activeThemeIndex int + +func activeTheme() Theme { + return themes[activeThemeIndex] +} + +func cycleTheme() Theme { + activeThemeIndex = (activeThemeIndex + 1) % len(themes) + applyTheme() + saveTheme() + return themes[activeThemeIndex] +} + +func glamourStyle() string { + if themes[activeThemeIndex].Dark { + return "dark" + } + return "light" +} + +func themePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".nib", "theme") +} + +func loadTheme() { + p := themePath() + if p == "" { + return + } + data, err := os.ReadFile(p) + if err != nil { + return + } + name := strings.TrimSpace(string(data)) + for i, t := range themes { + if t.Name == name { + activeThemeIndex = i + return + } + } +} + +func saveTheme() { + p := themePath() + if p == "" { + return + } + _ = os.WriteFile(p, []byte(themes[activeThemeIndex].Name+"\n"), 0o644) +} -- 2.52.0