summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 19:34:01 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 19:34:01 +0200
commit5fe164e91e40e8a3f749f4143f7562f940bf9f67 (patch)
treed77b03c737628fa58171de28eb89720c96f203b2
parenta44f6ee30c11963552b5b90a19698873aa9b6b6d (diff)
feat(tui): detect terminal theme and apply palettes
-rw-r--r--internal/tui/common/styles.go128
-rw-r--r--internal/tui/common/styles_test.go39
-rw-r--r--internal/tui/dashboard/model.go12
-rw-r--r--internal/tui/eventstream/exportmodal.go7
-rw-r--r--internal/tui/eventstream/filtermodal.go7
-rw-r--r--internal/tui/eventstream/model.go14
-rw-r--r--internal/tui/eventstream/searchmodal.go7
-rw-r--r--internal/tui/pidpicker/model.go20
-rw-r--r--internal/tui/probes/model.go10
-rw-r--r--internal/tui/styles.go23
-rw-r--r--internal/tui/tui.go39
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 {