diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 19:34:01 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 19:34:01 +0200 |
| commit | 5fe164e91e40e8a3f749f4143f7562f940bf9f67 (patch) | |
| tree | d77b03c737628fa58171de28eb89720c96f203b2 | |
| parent | a44f6ee30c11963552b5b90a19698873aa9b6b6d (diff) | |
feat(tui): detect terminal theme and apply palettes
| -rw-r--r-- | internal/tui/common/styles.go | 128 | ||||
| -rw-r--r-- | internal/tui/common/styles_test.go | 39 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 12 | ||||
| -rw-r--r-- | internal/tui/eventstream/exportmodal.go | 7 | ||||
| -rw-r--r-- | internal/tui/eventstream/filtermodal.go | 7 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 14 | ||||
| -rw-r--r-- | internal/tui/eventstream/searchmodal.go | 7 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model.go | 20 | ||||
| -rw-r--r-- | internal/tui/probes/model.go | 10 | ||||
| -rw-r--r-- | internal/tui/styles.go | 23 | ||||
| -rw-r--r-- | internal/tui/tui.go | 39 |
11 files changed, 260 insertions, 46 deletions
diff --git a/internal/tui/common/styles.go b/internal/tui/common/styles.go index 06ed596..a71ef81 100644 --- a/internal/tui/common/styles.go +++ b/internal/tui/common/styles.go @@ -1,59 +1,117 @@ package common -import "charm.land/lipgloss/v2" +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// Palette defines themed colors shared across the TUI package. +type Palette struct { + Background color.Color + Panel color.Color + Primary color.Color + Accent color.Color + Muted color.Color + Text color.Color + Danger color.Color +} + +// NewPalette returns a color palette for dark or light terminal backgrounds. +func NewPalette(isDark bool) Palette { + if isDark { + return Palette{ + Background: lipgloss.Color("235"), + Panel: lipgloss.Color("238"), + Primary: lipgloss.Color("75"), + Accent: lipgloss.Color("222"), + Muted: lipgloss.Color("246"), + Text: lipgloss.Color("255"), + Danger: lipgloss.Color("203"), + } + } + + return Palette{ + Background: lipgloss.Color("255"), + Panel: lipgloss.Color("250"), + Primary: lipgloss.Color("26"), + Accent: lipgloss.Color("88"), + Muted: lipgloss.Color("242"), + Text: lipgloss.Color("235"), + Danger: lipgloss.Color("160"), + } +} var ( // Palette colors shared across the TUI package. - ColorBackground = lipgloss.Color("235") - ColorPanel = lipgloss.Color("238") - ColorPrimary = lipgloss.Color("75") - ColorAccent = lipgloss.Color("222") - ColorMuted = lipgloss.Color("246") - ColorText = lipgloss.Color("255") - ColorDanger = lipgloss.Color("203") + ColorBackground color.Color + ColorPanel color.Color + ColorPrimary color.Color + ColorAccent color.Color + ColorMuted color.Color + ColorText color.Color + ColorDanger color.Color ) var ( // ScreenStyle is the base style for full-screen models. - ScreenStyle = lipgloss.NewStyle(). - Foreground(ColorText) + ScreenStyle lipgloss.Style // HeaderStyle is used by top-level titles and screen headers. - HeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorPrimary) + HeaderStyle lipgloss.Style // TabActiveStyle is applied to the currently-selected tab. - TabActiveStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorBackground). - Background(ColorPrimary). - Padding(0, 1) + TabActiveStyle lipgloss.Style // TabInactiveStyle is applied to non-selected tabs. - TabInactiveStyle = lipgloss.NewStyle(). - Foreground(ColorMuted). - Padding(0, 1) + TabInactiveStyle lipgloss.Style // PanelStyle is used for boxed sections. - PanelStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(ColorPanel). - Padding(0, 1) + PanelStyle lipgloss.Style // HelpBarStyle is used for keybinding hints at the bottom. - HelpBarStyle = lipgloss.NewStyle(). - Foreground(ColorMuted). - BorderTop(true). - BorderForeground(ColorPanel) + HelpBarStyle lipgloss.Style // HighlightStyle emphasizes inline values. - HighlightStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorAccent) + HighlightStyle lipgloss.Style // ErrorStyle is used for fatal or warning messages. - ErrorStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(ColorDanger) + ErrorStyle lipgloss.Style ) + +// ApplyPalette updates shared colors and styles to match the provided theme. +func ApplyPalette(isDark bool) { + palette := NewPalette(isDark) + ColorBackground = palette.Background + ColorPanel = palette.Panel + ColorPrimary = palette.Primary + ColorAccent = palette.Accent + ColorMuted = palette.Muted + ColorText = palette.Text + ColorDanger = palette.Danger + + ScreenStyle = lipgloss.NewStyle().Foreground(ColorText) + HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary) + TabActiveStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorBackground). + Background(ColorPrimary). + Padding(0, 1) + TabInactiveStyle = lipgloss.NewStyle(). + Foreground(ColorMuted). + Padding(0, 1) + PanelStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(ColorPanel). + Padding(0, 1) + HelpBarStyle = lipgloss.NewStyle(). + Foreground(ColorMuted). + BorderTop(true). + BorderForeground(ColorPanel) + HighlightStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent) + ErrorStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorDanger) +} + +func init() { + ApplyPalette(true) +} diff --git a/internal/tui/common/styles_test.go b/internal/tui/common/styles_test.go new file mode 100644 index 0000000..c0900b3 --- /dev/null +++ b/internal/tui/common/styles_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "testing" + + "charm.land/lipgloss/v2" +) + +func TestNewPaletteRendersDistinctThemes(t *testing.T) { + dark := NewPalette(true) + light := NewPalette(false) + + darkRender := lipgloss.NewStyle(). + Foreground(dark.Text). + Background(dark.Background). + Render("ior") + lightRender := lipgloss.NewStyle(). + Foreground(light.Text). + Background(light.Background). + Render("ior") + + if darkRender == lightRender { + t.Fatalf("expected dark and light palettes to render differently") + } +} + +func TestApplyPaletteUpdatesSharedStyles(t *testing.T) { + t.Cleanup(func() { ApplyPalette(true) }) + + ApplyPalette(true) + dark := ScreenStyle.Render("ior") + + ApplyPalette(false) + light := ScreenStyle.Render("ior") + + if dark == light { + t.Fatalf("expected ScreenStyle render to differ between dark and light palettes") + } +} diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index ac61982..a4cd4a5 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -47,6 +47,7 @@ type Model struct { processesOffset int streamModel eventstream.Model showHelp bool + isDark bool } // NewModel creates a dashboard model with default refresh cadence. @@ -59,14 +60,17 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf if refreshMs <= 0 { refreshMs = defaultRefreshMs } - return Model{ + m := Model{ activeTab: TabOverview, engine: engine, refreshEvery: time.Duration(refreshMs) * time.Millisecond, keys: keys, pidFilter: -1, streamModel: eventstream.NewModel(streamSource), + isDark: true, } + m.SetDarkMode(true) + return m } // Init starts periodic refresh ticks. @@ -282,6 +286,12 @@ func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { m.streamModel.SetSource(source) } +// SetDarkMode updates dashboard child models for the active theme. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.streamModel.SetDarkMode(isDark) +} + // SetPidFilter updates the active PID filter used by tab render hints. func (m *Model) SetPidFilter(pid int) { m.pidFilter = pid diff --git a/internal/tui/eventstream/exportmodal.go b/internal/tui/eventstream/exportmodal.go index ec35343..3c0e2cd 100644 --- a/internal/tui/eventstream/exportmodal.go +++ b/internal/tui/eventstream/exportmodal.go @@ -19,6 +19,7 @@ func NewExportModal() ExportModal { input.Prompt = "" input.CharLimit = 0 input.SetWidth(44) + input.SetStyles(textinput.DefaultStyles(true)) return ExportModal{textInput: input} } @@ -26,6 +27,12 @@ func (m ExportModal) Visible() bool { return m.visible } +// SetDarkMode updates export modal text input styles. +func (m ExportModal) SetDarkMode(isDark bool) ExportModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m ExportModal) Open(defaultName string) ExportModal { m.visible = true m.err = "" diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go index 6c7653e..bd20a03 100644 --- a/internal/tui/eventstream/filtermodal.go +++ b/internal/tui/eventstream/filtermodal.go @@ -49,6 +49,7 @@ func NewFilterModal() FilterModal { input.Prompt = "" input.CharLimit = 0 input.SetWidth(24) + input.SetStyles(textinput.DefaultStyles(true)) m := FilterModal{textInput: input} m.fields = defaultFilterFields() @@ -63,6 +64,12 @@ func (m FilterModal) Filter() Filter { return m.filter } +// SetDarkMode updates filter modal text input styles. +func (m FilterModal) SetDarkMode(isDark bool) FilterModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m FilterModal) Open(initial Filter) FilterModal { m.visible = true m.activeField = 0 diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index af7f67d..68b0cd5 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -53,6 +53,7 @@ type Model struct { pendingOpenPath string statusMessage string exportDir string + isDark bool width int height int @@ -69,7 +70,7 @@ type fdTraceViewState struct { } func NewModel(source *RingBuffer) Model { - return Model{ + m := Model{ source: source, filterModal: NewFilterModal(), exportModal: NewExportModal(), @@ -79,7 +80,10 @@ func NewModel(source *RingBuffer) Model { selectedCol: 0, exportDir: ".", showFooter: true, + isDark: true, } + m.SetDarkMode(true) + return m } // SetViewport updates the render/scroll viewport dimensions used for @@ -104,6 +108,14 @@ func (m *Model) SetSource(source *RingBuffer) { m.Refresh() } +// SetDarkMode updates stream modal text input styles for the active theme. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.filterModal = m.filterModal.SetDarkMode(isDark) + m.exportModal = m.exportModal.SetDarkMode(isDark) + m.searchModal = m.searchModal.SetDarkMode(isDark) +} + // FilterModalVisible reports whether the filter modal is currently open. func (m Model) FilterModalVisible() bool { return m.filterModal.Visible() diff --git a/internal/tui/eventstream/searchmodal.go b/internal/tui/eventstream/searchmodal.go index 892b6d9..c09542b 100644 --- a/internal/tui/eventstream/searchmodal.go +++ b/internal/tui/eventstream/searchmodal.go @@ -27,6 +27,7 @@ func NewSearchModal() SearchModal { input.Prompt = "" input.CharLimit = 0 input.SetWidth(44) + input.SetStyles(textinput.DefaultStyles(true)) return SearchModal{textInput: input, direction: SearchForward} } @@ -38,6 +39,12 @@ func (m SearchModal) Direction() SearchDirection { return m.direction } +// SetDarkMode updates search modal text input styles. +func (m SearchModal) SetDarkMode(isDark bool) SearchModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m SearchModal) Open(direction SearchDirection, defaultTerm string) SearchModal { m.visible = true m.err = "" diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go index 187be52..87c200c 100644 --- a/internal/tui/pidpicker/model.go +++ b/internal/tui/pidpicker/model.go @@ -50,6 +50,14 @@ var ( errorStyle = common.ErrorStyle ) +func syncPickerStyles() { + screenStyle = common.ScreenStyle + headerStyle = common.HeaderStyle + helpBarStyle = common.HelpBarStyle + highlightStyle = common.HighlightStyle + errorStyle = common.ErrorStyle +} + type processesLoadedMsg struct { processes []ProcessInfo err error @@ -67,6 +75,7 @@ type Model struct { height int keys KeyMap lastErr error + isDark bool } // New creates a PID picker model with default shared key bindings. @@ -81,12 +90,14 @@ func NewWithKeys(keys KeyMap) Model { // NewPIDWithKeys creates a PID picker model with the provided key bindings. func NewPIDWithKeys(keys KeyMap) Model { + syncPickerStyles() input := textinput.New() input.Prompt = "Filter: " input.Placeholder = "pid, comm, or cmdline" input.Focus() input.CharLimit = 0 input.SetWidth(40) + input.SetStyles(textinput.DefaultStyles(true)) return Model{ input: input, @@ -94,6 +105,7 @@ func NewPIDWithKeys(keys KeyMap) Model { filtered: []ProcessInfo{}, mode: PickerModePID, targetPID: -1, + isDark: true, } } @@ -268,6 +280,14 @@ func (m Model) View() tea.View { return tea.NewView(screenStyle.Render(b.String())) } +// SetDarkMode updates picker theme and text input styles. +func (m Model) SetDarkMode(isDark bool) Model { + m.isDark = isDark + syncPickerStyles() + m.input.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m Model) renderRows() string { lines := make([]string, 0, len(m.filtered)+1) allLabel := allPIDsLabel diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go index c4512f9..7da27db 100644 --- a/internal/tui/probes/model.go +++ b/internal/tui/probes/model.go @@ -39,6 +39,7 @@ type Model struct { lastErr string manager Manager height int + isDark bool } func NewModel(manager Manager) Model { @@ -46,9 +47,11 @@ func NewModel(manager Manager) Model { ti.Prompt = "/ " ti.CharLimit = 0 ti.SetWidth(28) + ti.SetStyles(textinput.DefaultStyles(true)) return Model{ manager: manager, textInput: ti, + isDark: true, } } @@ -72,6 +75,13 @@ func (m Model) Close() Model { return m } +// SetDarkMode updates probe modal text input styles. +func (m Model) SetDarkMode(isDark bool) Model { + m.isDark = isDark + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.visible { return m, nil diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 3bf69f7..9f7a16a 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -38,3 +38,26 @@ var ( // ErrorStyle is used for fatal or warning messages. ErrorStyle = common.ErrorStyle ) + +func syncStylesFromCommon() { + ColorBackground = common.ColorBackground + ColorPanel = common.ColorPanel + ColorPrimary = common.ColorPrimary + ColorAccent = common.ColorAccent + ColorMuted = common.ColorMuted + ColorText = common.ColorText + ColorDanger = common.ColorDanger + + ScreenStyle = common.ScreenStyle + HeaderStyle = common.HeaderStyle + TabActiveStyle = common.TabActiveStyle + TabInactiveStyle = common.TabInactiveStyle + PanelStyle = common.PanelStyle + HelpBarStyle = common.HelpBarStyle + HighlightStyle = common.HighlightStyle + ErrorStyle = common.ErrorStyle +} + +func init() { + syncStylesFromCommon() +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6c08c2f..a12554a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -173,6 +173,7 @@ type Model struct { pidFilter int exportEnabled bool + isDark bool } // NewModel creates the top-level TUI model. @@ -182,6 +183,9 @@ func NewModel(initialPID int, startTrace TraceStarter) Model { } func newModelWithRuntimeConfig(initialPID, startupPidFilter int, exportEnabled bool, startTrace TraceStarter) Model { + common.ApplyPalette(true) + syncStylesFromCommon() + spin := spinner.New() spin.Spinner = spinner.MiniDot if startTrace == nil { @@ -194,6 +198,7 @@ func newModelWithRuntimeConfig(initialPID, startupPidFilter int, exportEnabled b runtime := newRuntimeBindings() dashboard := dashboardui.NewModelWithConfig(lateBoundDashboardSource{runtime: runtime}, runtime.eventStreamSource(), 1000, keys) + dashboard.SetDarkMode(true) pidFilter := selectedPIDFilter(startupPidFilter) if initialPID > 0 { pidFilter = selectedPIDFilter(initialPID) @@ -202,16 +207,17 @@ func newModelWithRuntimeConfig(initialPID, startupPidFilter int, exportEnabled b model := Model{ screen: ScreenPIDPicker, - pidPicker: pidpicker.New(), + pidPicker: pidpicker.New().SetDarkMode(true), dashboard: dashboard, exporter: tuiexport.NewModel(), - probeModal: probes.NewModel(runtime.currentProbeManager()), + probeModal: probes.NewModel(runtime.currentProbeManager()).SetDarkMode(true), runtime: runtime, keys: keys, spin: spin, startTrace: startTrace, pidFilter: pidFilter, exportEnabled: exportEnabled, + isDark: true, } if initialPID > 0 { @@ -227,9 +233,9 @@ func newModelWithRuntimeConfig(initialPID, startupPidFilter int, exportEnabled b func (m Model) Init() tea.Cmd { sizeCmd := initialWindowSizeCmd() if m.screen == ScreenDashboard && m.attaching { - return tea.Batch(sizeCmd, tea.RequestWindowSize, m.spin.Tick, m.beginTraceCmd()) + return tea.Batch(sizeCmd, tea.RequestWindowSize, tea.RequestBackgroundColor, m.spin.Tick, m.beginTraceCmd()) } - return tea.Batch(sizeCmd, tea.RequestWindowSize, m.pidPicker.Init()) + return tea.Batch(sizeCmd, tea.RequestWindowSize, tea.RequestBackgroundColor, m.pidPicker.Init()) } func initialWindowSizeCmd() tea.Cmd { @@ -246,6 +252,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height return m.updateActiveModel(msg) + case tea.BackgroundColorMsg: + m.applyTheme(msg.IsDark()) + return m, nil case tea.KeyPressMsg: if key.Matches(msg, m.keys.Quit) { m.quitting = true @@ -257,7 +266,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Probes) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { - m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).Open() + m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open() return m, nil } if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectPID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() { @@ -383,8 +392,8 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { m.attaching = false m.lastErr = nil m.exporter = tuiexport.NewModel() - m.probeModal = probes.NewModel(m.runtime.currentProbeManager()) - m.pidPicker = pidpicker.New() + m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark) + m.pidPicker = pidpicker.New().SetDarkMode(m.isDark) var sizeCmd tea.Cmd if m.width > 0 && m.height > 0 { @@ -404,8 +413,8 @@ func (m Model) reselectTID() (tea.Model, tea.Cmd) { m.attaching = false m.lastErr = nil m.exporter = tuiexport.NewModel() - m.probeModal = probes.NewModel(m.runtime.currentProbeManager()) - m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()) + m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark) + m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()).SetDarkMode(m.isDark) var sizeCmd tea.Cmd if m.width > 0 && m.height > 0 { @@ -454,6 +463,18 @@ func (m *Model) stopTrace() { } } +func (m *Model) applyTheme(isDark bool) { + if m.isDark == isDark { + return + } + m.isDark = isDark + common.ApplyPalette(isDark) + syncStylesFromCommon() + m.dashboard.SetDarkMode(isDark) + m.pidPicker = m.pidPicker.SetDarkMode(isDark) + m.probeModal = m.probeModal.SetDarkMode(isDark) +} + // View renders the currently active screen and startup overlay state. func (m Model) View() tea.View { if m.quitting { |
