feat: theme config key, Ctrl+T cycle, themed status bar, glamour sync
6f9f840b541ae385ce705b67f4af249622cf7853
humdrum <me@humdrum.me> · 2026-06-28 09:26
parent 8817cc29
5 files changed
internal/app/app.go +34 −7
@@ -13,6 +13,7 @@ "glint/internal/config"
"glint/internal/editor"
"glint/internal/picker"
"glint/internal/preview"
+ "glint/internal/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -40,6 +41,7 @@ // App is the root model.
type App struct {
mode Mode
cfg config.Config
+ theme theme.Theme
editor *editor.Editor
preview *preview.Model
picker *picker.Model
@@ -54,12 +56,17 @@ }
// New builds an App with an empty editor.
func New(cfg config.Config) *App {
- return &App{
- mode: ModeEditor,
- cfg: cfg,
- editor: editor.New(),
- preview: preview.New(cfg.GlamourStyle),
+ th := theme.Resolve(cfg.Theme)
+ ed := editor.New()
+ ed.SetTheme(th)
+ a := &App{
+ mode: ModeEditor,
+ cfg: cfg,
+ theme: th,
+ editor: ed,
}
+ a.preview = preview.New(a.glamourStyle())
+ return a
}
// Load reads a file into the editor and switches to edit mode.
@@ -129,6 +136,8 @@ case tea.KeyCtrlS:
return a.save()
case tea.KeyCtrlR:
return a.togglePreview()
+ case tea.KeyCtrlT:
+ return a.cycleTheme()
case tea.KeyCtrlP:
if a.editor.Dirty && a.pending != discardPicker {
a.pending = discardPicker
@@ -183,6 +192,24 @@ a.status = "Saved " + a.path
return a, nil
}
+// glamourStyle is the explicit config override if set, else the theme's style.
+func (a *App) glamourStyle() string {
+ if a.cfg.GlamourStyle != "" {
+ return a.cfg.GlamourStyle
+ }
+ return a.theme.GlamourStyle
+}
+
+// cycleTheme advances to the next theme and repaints the editor, preview, and
+// (if open) the picker.
+func (a *App) cycleTheme() (tea.Model, tea.Cmd) {
+ a.theme = theme.Next(a.theme.Name)
+ a.editor.SetTheme(a.theme)
+ a.preview.SetStyle(a.glamourStyle())
+ a.status = "Theme: " + a.theme.Name
+ return a, nil
+}
+
// togglePreview switches between the editor and the Glamour read view.
func (a *App) togglePreview() (tea.Model, tea.Cmd) {
if a.mode == ModePreview {
@@ -258,8 +285,8 @@ }
func (a *App) statusBar() string {
bar := lipgloss.NewStyle().
- Foreground(lipgloss.Color("#1a1a24")).
- Background(lipgloss.Color("#7aa2f7")).
+ Foreground(a.theme.StatusFg).
+ Background(a.theme.StatusBg).
Width(maxInt(a.width, 1))
dirty := ""
if a.editor.Dirty {
internal/app/app_test.go +18 −0
@@ -295,3 +295,21 @@ if _, err := os.Stat(a.path); err != nil {
t.Errorf("second Ctrl+D: daily file should exist on disk: %v", err)
}
}
+
+func TestCtrlTCyclesTheme(t *testing.T) {
+ cfg := config.Default()
+ cfg.Theme = "flexoki-light"
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ if a.theme.Name != "flexoki-light" {
+ t.Fatalf("initial theme = %q, want flexoki-light", a.theme.Name)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
+ if a.theme.Name != "flexoki-dark" {
+ t.Errorf("after one Ctrl+T = %q, want flexoki-dark", a.theme.Name)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
+ if a.theme.Name != "charm" {
+ t.Errorf("after two Ctrl+T = %q, want charm", a.theme.Name)
+ }
+}
internal/config/config.go +8 −4
@@ -17,16 +17,17 @@ VaultDir string `toml:"vault_dir"`
DailySubdir string `toml:"daily_subdir"`
DailyFormat string `toml:"daily_format"`
GlamourStyle string `toml:"glamour_style"`
+ Theme string `toml:"theme"`
}
// Default returns the built-in configuration used when no file is present.
func Default() Config {
home, _ := os.UserHomeDir()
return Config{
- VaultDir: filepath.Join(home, "Humdrum"),
- DailySubdir: "Daily",
- DailyFormat: "2006-01-02",
- GlamourStyle: "dark",
+ VaultDir: filepath.Join(home, "Humdrum"),
+ DailySubdir: "Daily",
+ DailyFormat: "2006-01-02",
+ Theme: "auto",
}
}
@@ -65,6 +66,9 @@ cfg.DailyFormat = fileCfg.DailyFormat
}
if fileCfg.GlamourStyle != "" {
cfg.GlamourStyle = fileCfg.GlamourStyle
+ }
+ if fileCfg.Theme != "" {
+ cfg.Theme = fileCfg.Theme
}
return cfg, nil
}
internal/config/config_test.go +22 −4
@@ -15,8 +15,11 @@ }
if d.DailyFormat != "2006-01-02" {
t.Errorf("DailyFormat = %q, want 2006-01-02", d.DailyFormat)
}
- if d.GlamourStyle != "dark" {
- t.Errorf("GlamourStyle = %q, want dark", d.GlamourStyle)
+ if d.GlamourStyle != "" {
+ t.Errorf("GlamourStyle default = %q, want empty (theme drives it)", d.GlamourStyle)
+ }
+ if d.Theme != "auto" {
+ t.Errorf("Theme = %q, want auto", d.Theme)
}
if d.VaultDir == "" {
t.Error("VaultDir should not be empty")
@@ -57,8 +60,23 @@ if cfg.DailyFormat != "20060102" {
t.Errorf("DailyFormat = %q", cfg.DailyFormat)
}
// unset key keeps default
- if cfg.GlamourStyle != "dark" {
- t.Errorf("GlamourStyle = %q, want default dark", cfg.GlamourStyle)
+ if cfg.GlamourStyle != "" {
+ t.Errorf("GlamourStyle = %q, want default empty", cfg.GlamourStyle)
+ }
+}
+
+func TestLoadFromFileOverlaysTheme(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ if err := os.WriteFile(path, []byte("theme = \"charm\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadFromFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Theme != "charm" {
+ t.Errorf("Theme = %q, want charm", cfg.Theme)
}
}
internal/preview/preview.go +3 −0
@@ -40,6 +40,9 @@ m.vp.Width = w
m.vp.Height = h
}
+// SetStyle changes the glamour style used by the next Render.
+func (m *Model) SetStyle(s string) { m.style = s }
+
// Render runs markdown through Glamour and loads it into the viewport.
func (m *Model) Render(markdown string) error {
r, err := m.renderer()