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..7a273f19 --- /dev/null +++ b/internal/tui/inputs.go @@ -0,0 +1,79 @@ +package tui + +import ( + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" +) + +// newTextArea and newTextInput are how every text field in the TUI is built. +// +// 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(textAreaStyles()) + return field +} + +func newTextInput() textinput.Model { + field := textinput.New() + 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 new file mode 100644 index 00000000..366a1326 --- /dev/null +++ b/internal/tui/inputs_test.go @@ -0,0 +1,53 @@ +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +// 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()) }) + + render := func(dark bool) (area, input string) { + theme := defaultTheme() + theme.Dark = dark + applyTheme(theme) + + 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() + } + + 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 darkInput != lightInput { + t.Errorf("textinput renders differently on a light theme:\n%q\n%q", darkInput, lightInput) + } + + // 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 !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/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/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}