From 0a2e1ce5ec56a9eb06bb947a5d02c485094c7276 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 26 Aug 2026 15:37:03 -0500 Subject: [PATCH 1/2] Fix that email and journal composers were unreadable in light themes (macOS) - New internal/tui/inputs.go with two helpers, newTextArea() and newTextInput(). Each builds the bubbles widget and then calls SetStyles(DefaultStyles(themeDark)), so the field takes the light or the dark palette from the theme instead of bubbles' hardcoded dark one. The comment explains the slot-0 / slot-7 cause and why Omarchy hides it. - applyTheme in styles.go now records themeDark = theme.Dark, so the helpers follow the same theme every other color follows. - All 17 construction sites in 14 files now call the helpers. No direct textarea.New() or textinput.New() remains in the package. - New inputs_test.go: TestTextFieldsFollowTheThemeMode flips theme.Dark and asserts the textarea's cursor line and the textinput's blurred text take bubbles' light default on a light theme, the dark default on a dark theme, and differ between the two. It is the counterpart of TestCoversDoNotDependOnTheThemeMode. --- internal/tui/bulk_reply.go | 2 +- internal/tui/compose.go | 4 +-- internal/tui/contact_form.go | 4 +-- internal/tui/datetime.go | 2 +- internal/tui/event_form.go | 4 +-- internal/tui/folders.go | 2 +- internal/tui/habit_form.go | 2 +- internal/tui/inputs.go | 29 ++++++++++++++++++++++ internal/tui/inputs_test.go | 43 +++++++++++++++++++++++++++++++++ internal/tui/journal.go | 2 +- internal/tui/journal_form.go | 2 +- internal/tui/search.go | 2 +- internal/tui/snippets.go | 2 +- internal/tui/styles.go | 1 + internal/tui/time_track.go | 2 +- internal/tui/time_track_form.go | 2 +- internal/tui/todos.go | 2 +- 17 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 internal/tui/inputs.go create mode 100644 internal/tui/inputs_test.go diff --git a/internal/tui/bulk_reply.go b/internal/tui/bulk_reply.go index 84cac09c..f7777540 100644 --- a/internal/tui/bulk_reply.go +++ b/internal/tui/bulk_reply.go @@ -60,7 +60,7 @@ func newBulkReplyForm(postingIDs []int64, draft *generated.BulkReplyDraft, s sty } form.preview = viewport.New(viewport.WithWidth(80), viewport.WithHeight(24)) form.preview.SetContent(form.previewContent(80)) - form.body = textarea.New() + form.body = newTextArea() form.body.Prompt = "" form.body.ShowLineNumbers = false form.body.Placeholder = "Write the reply that every selected thread will receive…" diff --git a/internal/tui/compose.go b/internal/tui/compose.go index dc909bd6..357abdac 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -107,12 +107,12 @@ func newComposeForm(mode composeMode, s styles) *composeForm { labels = append(labels, "Subject") } for _, l := range labels { - in := textinput.New() + in := newTextInput() in.Prompt = "" in.Placeholder = placeholderFor(l) f.inputs = append(f.inputs, in) } - f.body = textarea.New() + f.body = newTextArea() f.body.Prompt = "" f.body.ShowLineNumbers = false f.body.Placeholder = "Write your message… Markdown works here" diff --git a/internal/tui/contact_form.go b/internal/tui/contact_form.go index dc171684..c8502cd2 100644 --- a/internal/tui/contact_form.go +++ b/internal/tui/contact_form.go @@ -38,7 +38,7 @@ func newContactForm(mode contactFormMode, contact Contact, styles styles) *conta form := &contactForm{mode: mode, contactID: contact.ID, styles: styles} placeholders := []string{"Jane Doe", "jane@example.com", "jane.doe@example.org, jane@example.net"} for _, placeholder := range placeholders { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = placeholder form.inputs = append(form.inputs, input) @@ -170,7 +170,7 @@ type contactNoteForm struct { } func newContactNoteForm(contactID int64, note string, styles styles) *contactNoteForm { - input := textarea.New() + input := newTextArea() input.Prompt = "" input.ShowLineNumbers = false input.Placeholder = "Add a private note…" diff --git a/internal/tui/datetime.go b/internal/tui/datetime.go index f5bb7577..6cad72ee 100644 --- a/internal/tui/datetime.go +++ b/internal/tui/datetime.go @@ -75,7 +75,7 @@ func newDateTimePicker(at time.Time, allDay bool) *dateTimePicker { } func dateTimeInput(placeholder string, width int) textinput.Model { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = placeholder input.SetWidth(width) diff --git a/internal/tui/event_form.go b/internal/tui/event_form.go index 884c6255..ce301315 100644 --- a/internal/tui/event_form.go +++ b/internal/tui/event_form.go @@ -188,7 +188,7 @@ func newEventForm(mode eventFormMode, event Recording, on time.Time, calendars [ } func eventInput(placeholder string, width int) textinput.Model { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = placeholder if width > 0 { @@ -200,7 +200,7 @@ func eventInput(placeholder string, width int) textinput.Model { // eventNotesInput is the one field somebody writes a paragraph into, so it takes several lines // and enter puts a new one in rather than moving on. Tab is how the reader leaves it. func eventNotesInput() textarea.Model { - input := textarea.New() + input := newTextArea() input.Prompt = "" input.ShowLineNumbers = false input.Placeholder = "Agenda, what to bring, anything" diff --git a/internal/tui/folders.go b/internal/tui/folders.go index 7baf107b..8ea9d4c3 100644 --- a/internal/tui/folders.go +++ b/internal/tui/folders.go @@ -52,7 +52,7 @@ func newFolderPicker(posting mail.Posting, sources []mail.Source) *folderPicker folders = append(folders, source) } } - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = "Label name…" return &folderPicker{posting: posting, folders: folders, input: input} diff --git a/internal/tui/habit_form.go b/internal/tui/habit_form.go index 3d14acaf..ba394018 100644 --- a/internal/tui/habit_form.go +++ b/internal/tui/habit_form.go @@ -49,7 +49,7 @@ type habitForm struct { } func newHabitForm(mode habitFormMode, recording Recording, styles styles) *habitForm { - name := textinput.New() + name := newTextInput() name.Prompt = "" name.Placeholder = "Morning strength training" diff --git a/internal/tui/inputs.go b/internal/tui/inputs.go new file mode 100644 index 00000000..a18a223f --- /dev/null +++ b/internal/tui/inputs.go @@ -0,0 +1,29 @@ +package tui + +import ( + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" +) + +// themeDark is which side of the palette the active theme is on, kept by +// applyTheme for the text fields built below. +var themeDark = true + +// newTextArea and newTextInput are how every text field in the TUI is built. +// bubbles' New() hardcodes its dark palette — the focused cursor line on ANSI +// slot 0 and blurred text on slot 7 — which on a stock light terminal is a +// black band over light-grey text (hey-cli#331). Omarchy remaps those two +// slots to the theme's own paper and ink, which is why the defect never shows +// there. The theme knows which side it is on, so the fields are told rather +// than left to guess. Do not call textarea.New or textinput.New directly. +func newTextArea() textarea.Model { + field := textarea.New() + field.SetStyles(textarea.DefaultStyles(themeDark)) + return field +} + +func newTextInput() textinput.Model { + field := textinput.New() + field.SetStyles(textinput.DefaultStyles(themeDark)) + return field +} diff --git a/internal/tui/inputs_test.go b/internal/tui/inputs_test.go new file mode 100644 index 00000000..ad2e781c --- /dev/null +++ b/internal/tui/inputs_test.go @@ -0,0 +1,43 @@ +package tui + +import ( + "testing" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" +) + +// The text fields take their palette from the theme's mode, the way the cover art +// is guarded by TestCoversDoNotDependOnTheThemeMode — except that here the two +// modes must differ: bubbles' dark default paints a light terminal with a black +// cursor line and grey text (hey-cli#331). +func TestTextFieldsFollowTheThemeMode(t *testing.T) { + t.Cleanup(func() { applyTheme(defaultTheme()) }) + + dark := defaultTheme() + dark.Dark = true + applyTheme(dark) + darkArea, darkInput := newTextArea(), newTextInput() + + light := defaultTheme() + light.Dark = false + applyTheme(light) + lightArea, lightInput := newTextArea(), newTextInput() + + if got, want := darkArea.Styles().Focused.CursorLine.GetBackground(), textarea.DefaultDarkStyles().Focused.CursorLine.GetBackground(); got != want { + t.Errorf("dark textarea cursor line = %v, want bubbles' dark default %v", got, want) + } + if got, want := lightArea.Styles().Focused.CursorLine.GetBackground(), textarea.DefaultLightStyles().Focused.CursorLine.GetBackground(); got != want { + t.Errorf("light textarea cursor line = %v, want bubbles' light default %v", got, want) + } + if darkArea.Styles().Focused.CursorLine.GetBackground() == lightArea.Styles().Focused.CursorLine.GetBackground() { + t.Error("the textarea cursor line should change with the theme mode") + } + + if got, want := lightInput.Styles().Blurred.Text.GetForeground(), textinput.DefaultLightStyles().Blurred.Text.GetForeground(); got != want { + t.Errorf("light textinput blurred text = %v, want bubbles' light default %v", got, want) + } + if darkInput.Styles().Blurred.Text.GetForeground() == lightInput.Styles().Blurred.Text.GetForeground() { + t.Error("the textinput blurred text should change with the theme mode") + } +} diff --git a/internal/tui/journal.go b/internal/tui/journal.go index 915e61c8..e3601c64 100644 --- a/internal/tui/journal.go +++ b/internal/tui/journal.go @@ -86,7 +86,7 @@ type journalPrompt struct { } func newJournalPrompt(kind journalPromptKind, value string, styles styles) *journalPrompt { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.SetValue(value) if kind == journalPromptSearch { diff --git a/internal/tui/journal_form.go b/internal/tui/journal_form.go index 1336a8df..a5b852ae 100644 --- a/internal/tui/journal_form.go +++ b/internal/tui/journal_form.go @@ -29,7 +29,7 @@ type journalForm struct { } func newJournalForm(date, content string, styles styles) *journalForm { - input := textarea.New() + input := newTextArea() input.Prompt = "" input.ShowLineNumbers = false input.Placeholder = "Write about your day…" diff --git a/internal/tui/search.go b/internal/tui/search.go index 7ecf0446..422f5824 100644 --- a/internal/tui/search.go +++ b/internal/tui/search.go @@ -15,7 +15,7 @@ type mailSearchForm struct { } func newMailSearchForm(query string, styles styles) *mailSearchForm { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = "Search threads and messages…" input.SetValue(query) diff --git a/internal/tui/snippets.go b/internal/tui/snippets.go index b8fd0dca..15d23eee 100644 --- a/internal/tui/snippets.go +++ b/internal/tui/snippets.go @@ -35,7 +35,7 @@ type snippetPicker struct { } func newSnippetPicker(returnFocus int) *snippetPicker { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = "Filter snippets…" return &snippetPicker{input: input, cursor: -1, loading: true, returnFocus: returnFocus} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f717d815..aaba3518 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -61,6 +61,7 @@ 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) { + themeDark = theme.Dark colorPrimary = theme.Accent colorMuted = theme.Muted colorBright = theme.Bright diff --git a/internal/tui/time_track.go b/internal/tui/time_track.go index 8c59aa93..5a68ad37 100644 --- a/internal/tui/time_track.go +++ b/internal/tui/time_track.go @@ -442,7 +442,7 @@ type timeTrackCategoryManager struct { } func newTimeTrackCategoryManager() *timeTrackCategoryManager { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = "Category title…" return &timeTrackCategoryManager{input: input} diff --git a/internal/tui/time_track_form.go b/internal/tui/time_track_form.go index 05b56f30..06a4facf 100644 --- a/internal/tui/time_track_form.go +++ b/internal/tui/time_track_form.go @@ -87,7 +87,7 @@ func newTimeTrackForm(track trackedTime, categories []generated.TimeTrackCategor } func trackInput(placeholder string) textinput.Model { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = placeholder return input diff --git a/internal/tui/todos.go b/internal/tui/todos.go index 74c2b6b6..eec7817a 100644 --- a/internal/tui/todos.go +++ b/internal/tui/todos.go @@ -33,7 +33,7 @@ type todoPicker struct { } func newTodoPicker(todos []Recording) *todoPicker { - input := textinput.New() + input := newTextInput() input.Prompt = "" input.Placeholder = "Renew passport" return &todoPicker{todos: todos, input: input} From cc9108fb01e682757b4b094d429168752ab94e45 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 26 Aug 2026 15:49:37 -0500 Subject: [PATCH 2/2] Make this mode-independent like the rest of the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inputs.go now builds textarea.Styles and textinput.Styles that name no color. Text uses the terminal's default foreground. Placeholders, line numbers, end-of-buffer marks, and blurred text use styleMuted (the SGR faint attribute, the same as every other secondary text in the TUI). A selection uses reverse video. The focused cursor line has no background at all — the cursor marks the line, and the band was the defect. The cursor keeps bubbles' block shape and blink, with no color, so the terminal's own cursor color applies. - The themeDark variable is gone, and applyTheme no longer records the mode. Nothing here depends on Theme.Dark anymore. - The theme flip needs no re-apply: a switch retints the sixteen slots over OSC 4, and these styles use only default foreground and attributes, so open fields follow along like the rest of the screen. - The test is inverted to match: TestTextFieldsDoNotDependOnTheThemeMode renders a focused textarea and textinput under a dark theme and a light theme and requires byte-identical output. It also asserts the cursor line carries no background and no \x1b[40m band. This is the same guard the covers have. --- internal/tui/inputs.go | 74 +++++++++++++++++++++++++++++++------ internal/tui/inputs_test.go | 62 ++++++++++++++++++------------- internal/tui/styles.go | 1 - 3 files changed, 98 insertions(+), 39 deletions(-) diff --git a/internal/tui/inputs.go b/internal/tui/inputs.go index a18a223f..7a273f19 100644 --- a/internal/tui/inputs.go +++ b/internal/tui/inputs.go @@ -3,27 +3,77 @@ package tui import ( "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" ) -// themeDark is which side of the palette the active theme is on, kept by -// applyTheme for the text fields built below. -var themeDark = true - // newTextArea and newTextInput are how every text field in the TUI is built. -// bubbles' New() hardcodes its dark palette — the focused cursor line on ANSI -// slot 0 and blurred text on slot 7 — which on a stock light terminal is a -// black band over light-grey text (hey-cli#331). Omarchy remaps those two -// slots to the theme's own paper and ink, which is why the defect never shows -// there. The theme knows which side it is on, so the fields are told rather -// than left to guess. Do not call textarea.New or textinput.New directly. +// +// bubbles' New() hands a field a palette chosen for one background: the focused +// cursor line on ANSI slot 0, blurred text on slot 7, placeholders in the +// 256-color cube. On a stock light terminal that is a black band over grey +// text (hey-cli#331); Omarchy remaps slots 0 and 7 to the theme's own paper +// and ink, which is why the defect never shows there. Choosing the other +// palette for a light theme would fix the band and leave a field that has a +// mode at all — stale the moment the theme flips under it, since a widget keeps +// the Styles it was handed. +// +// So these styles name no color. Like the rest of styles.go they lean on what +// the terminal already decided: default foreground for text, the SGR faint +// attribute (styleMuted) for what is secondary, reverse video for a selection. +// A theme switch retints all of it over OSC 4 with nothing to re-apply, and +// flipping Theme.Dark changes no byte of the output — TestTextFieldsDoNotDependOnTheThemeMode +// holds that line the way TestCoversDoNotDependOnTheThemeMode does for the covers. +// Do not call textarea.New or textinput.New directly. func newTextArea() textarea.Model { field := textarea.New() - field.SetStyles(textarea.DefaultStyles(themeDark)) + field.SetStyles(textAreaStyles()) return field } func newTextInput() textinput.Model { field := textinput.New() - field.SetStyles(textinput.DefaultStyles(themeDark)) + field.SetStyles(textInputStyles()) return field } + +func textAreaStyles() textarea.Styles { + plain := lipgloss.NewStyle() + focused := textarea.StyleState{ + Base: plain, + Text: plain, + LineNumber: styleMuted, + CursorLineNumber: plain, + CursorLine: plain, // no band: the cursor itself says where the line is + EndOfBuffer: styleMuted, + Placeholder: styleMuted, + Prompt: plain, + Selection: lipgloss.NewStyle().Reverse(true), + } + blurred := focused + blurred.Text = styleMuted + blurred.CursorLine = styleMuted + blurred.CursorLineNumber = styleMuted + return textarea.Styles{ + Focused: focused, + Blurred: blurred, + // No Color: the terminal's own cursor color, whatever the theme made it. + Cursor: textarea.CursorStyle{Shape: textarea.DefaultDarkStyles().Cursor.Shape, Blink: true}, + } +} + +func textInputStyles() textinput.Styles { + plain := lipgloss.NewStyle() + focused := textinput.StyleState{ + Text: plain, + Placeholder: styleMuted, + Suggestion: styleMuted, + Prompt: plain, + } + blurred := focused + blurred.Text = styleMuted + return textinput.Styles{ + Focused: focused, + Blurred: blurred, + Cursor: textinput.CursorStyle{Shape: textinput.DefaultDarkStyles().Cursor.Shape, Blink: true}, + } +} diff --git a/internal/tui/inputs_test.go b/internal/tui/inputs_test.go index ad2e781c..366a1326 100644 --- a/internal/tui/inputs_test.go +++ b/internal/tui/inputs_test.go @@ -1,43 +1,53 @@ package tui import ( + "strings" "testing" - "charm.land/bubbles/v2/textarea" - "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" ) -// The text fields take their palette from the theme's mode, the way the cover art -// is guarded by TestCoversDoNotDependOnTheThemeMode — except that here the two -// modes must differ: bubbles' dark default paints a light terminal with a black -// cursor line and grey text (hey-cli#331). -func TestTextFieldsFollowTheThemeMode(t *testing.T) { +// The text fields name no color of their own, so the theme's mode cannot change a +// byte of what they draw — the same guard TestCoversDoNotDependOnTheThemeMode gives +// the cover art. bubbles' default did depend on it: its dark palette paints a light +// terminal with a black cursor line and grey text (hey-cli#331). +func TestTextFieldsDoNotDependOnTheThemeMode(t *testing.T) { t.Cleanup(func() { applyTheme(defaultTheme()) }) - dark := defaultTheme() - dark.Dark = true - applyTheme(dark) - darkArea, darkInput := newTextArea(), newTextInput() + render := func(dark bool) (area, input string) { + theme := defaultTheme() + theme.Dark = dark + applyTheme(theme) - light := defaultTheme() - light.Dark = false - applyTheme(light) - lightArea, lightInput := newTextArea(), newTextInput() - - if got, want := darkArea.Styles().Focused.CursorLine.GetBackground(), textarea.DefaultDarkStyles().Focused.CursorLine.GetBackground(); got != want { - t.Errorf("dark textarea cursor line = %v, want bubbles' dark default %v", got, want) + a := newTextArea() + a.SetWidth(40) + a.SetHeight(3) + a.SetValue("Quarterly numbers for the board") + a.Focus() + i := newTextInput() + i.SetWidth(40) + i.SetValue("Jane Doe") + i.Focus() + return a.View(), i.View() } - if got, want := lightArea.Styles().Focused.CursorLine.GetBackground(), textarea.DefaultLightStyles().Focused.CursorLine.GetBackground(); got != want { - t.Errorf("light textarea cursor line = %v, want bubbles' light default %v", got, want) + + darkArea, darkInput := render(true) + lightArea, lightInput := render(false) + if darkArea != lightArea { + t.Errorf("textarea renders differently on a light theme:\n%q\n%q", darkArea, lightArea) } - if darkArea.Styles().Focused.CursorLine.GetBackground() == lightArea.Styles().Focused.CursorLine.GetBackground() { - t.Error("the textarea cursor line should change with the theme mode") + if darkInput != lightInput { + t.Errorf("textinput renders differently on a light theme:\n%q\n%q", darkInput, lightInput) } - if got, want := lightInput.Styles().Blurred.Text.GetForeground(), textinput.DefaultLightStyles().Blurred.Text.GetForeground(); got != want { - t.Errorf("light textinput blurred text = %v, want bubbles' light default %v", got, want) + // The focused cursor line carries no background: that band is the defect. + if bg, unset := textAreaStyles().Focused.CursorLine.GetBackground(), lipgloss.NewStyle().GetBackground(); bg != unset { + t.Errorf("focused cursor line has a background %v, want none", bg) + } + if strings.Contains(darkArea, "\x1b[40m") || strings.Contains(darkArea, "48;5;0m") { + t.Errorf("textarea paints a black cursor line: %q", darkArea) } - if darkInput.Styles().Blurred.Text.GetForeground() == lightInput.Styles().Blurred.Text.GetForeground() { - t.Error("the textinput blurred text should change with the theme mode") + if !strings.Contains(darkArea, "Quarterly numbers") || !strings.Contains(darkInput, "Jane Doe") { + t.Errorf("fields dropped their text:\n%q\n%q", darkArea, darkInput) } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index aaba3518..f717d815 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -61,7 +61,6 @@ 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) { - themeDark = theme.Dark colorPrimary = theme.Accent colorMuted = theme.Muted colorBright = theme.Bright