From 4479417dfc1a027fc616d07a7ed33828744c0b23 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:45:21 +0500 Subject: [PATCH 1/5] fix(picker): open in the dark palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Picker drew its first frame with dark false, so a dark terminal saw one washed-out frame — pale selected row, preview border bright enough to draw a box that then vanished — before tea.BackgroundColorMsg landed and repainted it. Dark is the safer assumption, and the one lipgloss makes when a terminal will not answer. themeSet went with it: it was written on the report and never read. Part of #55 Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/model.go | 6 +++++- internal/picker/update.go | 1 - internal/picker/view_test.go | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/picker/model.go b/internal/picker/model.go index 8f231a8..4e77132 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -42,7 +42,6 @@ type Model struct { width, height int dark bool - themeSet bool chosen bool chosenRow Row @@ -68,6 +67,11 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { query: opts.Query, focus: f, statuses: make(map[string]git.Status, len(rows)), + + // Dark until the terminal says otherwise, as lipgloss itself + // assumes: Run settles it before the first frame, and a light + // terminal that answers neither query is rarer than a dark one. + dark: true, } } diff --git a/internal/picker/update.go b/internal/picker/update.go index 15b4459..b228241 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -15,7 +15,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.BackgroundColorMsg: m.dark = msg.IsDark() - m.themeSet = true return m, nil case tea.WindowSizeMsg: diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go index c7710e7..3c18739 100644 --- a/internal/picker/view_test.go +++ b/internal/picker/view_test.go @@ -222,3 +222,24 @@ func TestModel_View_UsesAlternateScreen(t *testing.T) { t.Errorf("empty-History View().AltScreen = false, want true") } } + +// TestModel_View_FirstFrameNeedsNoRepaint pins the frame drawn before the +// terminal has reported its background to the frame drawn after a dark +// report. The reply to that query lands a frame or two in; opening in the +// light palette meant a dark terminal saw one washed-out frame, border box +// and all, flash past on launch. +func TestModel_View_FirstFrameNeedsNoRepaint(t *testing.T) { + for _, layout := range bothLayouts { + t.Run(string(layout), func(t *testing.T) { + m := sizedModel(manyRows(5), layout, 110, 30) + before := m.View().Content + + next, _ := m.Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + after := next.(picker.Model).View().Content + + if before != after { + t.Errorf("the first frame is not the dark palette's frame; it repaints when the background report lands:\nbefore:\n%s\nafter:\n%s", before, after) + } + }) + } +} From af804586d5f37492fce192bb64b8ed98afff652a Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:45:41 +0500 Subject: [PATCH 2/5] fix(picker): settle the palette before the program starts Bubble Tea reports the terminal background a frame or two in, so a light terminal still repainted itself on launch. Query it synchronously on the screen the Picker is about to draw on: lipgloss sends a device-attributes query alongside, so a terminal that ignores the background query ends this one promptly rather than waiting out the timeout. The asynchronous report stays as the correction for a query that goes unanswered. ttyProgramOptions became openScreen, which hands back the files as well as the options, and dropped an error it never returned. Part of #55 Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/model.go | 3 +- internal/picker/picker.go | 66 ++++++++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/internal/picker/model.go b/internal/picker/model.go index 4e77132..ba5e14e 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -93,7 +93,8 @@ type statusResultMsg struct { // Init fires one command per row that fetches its git status, fanned out // through tea.Batch and bounded by a semaphore so a large History does not // spawn unbounded concurrent git processes. It also requests the terminal -// background colour, used to pick the light or dark palette. +// background colour, which corrects the palette Run already settled on +// should its own synchronous query have gone unanswered. func (m Model) Init() tea.Cmd { sem := make(chan struct{}, concurrency) cmds := make([]tea.Cmd, 0, len(m.rows)+1) diff --git a/internal/picker/picker.go b/internal/picker/picker.go index 4de8ffd..af75aa1 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -13,6 +13,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/kryft-dev/cdd/internal/git" ) @@ -88,17 +89,15 @@ const concurrency = 8 // The Picker draws on /dev/tty via tea.OpenTTY, falling back to stderr when // no TTY is available. It never writes to stdout. func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { - m := NewModel(rows, status, opts) - - ttyOpts, cleanup, err := ttyProgramOptions() - if err != nil { - return Row{}, false, err - } - if cleanup != nil { - defer cleanup() + s := openScreen() + if s.cleanup != nil { + defer s.cleanup() } - p := tea.NewProgram(m, ttyOpts...) + m := NewModel(rows, status, opts) + m.dark = s.dark() + + p := tea.NewProgram(m, s.opts...) final, err := p.Run() if err != nil { return Row{}, false, err @@ -114,18 +113,47 @@ func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { return fm.chosenRow, true, nil } -// ttyProgramOptions builds the tea.ProgramOptions that make the Picker draw -// on /dev/tty, falling back to stderr when no TTY can be opened. stdout is -// never used, since a caller may pipe it (the Wrapper reads the chosen path -// from Run's return value, not from the program's own output). -func ttyProgramOptions() ([]tea.ProgramOption, func(), error) { +// screen is where the Picker draws: the files it reads from and writes to, +// and the Bubble Tea options pointing the program at them. +type screen struct { + in, out *os.File + opts []tea.ProgramOption + + // cleanup closes the files, when they are ours to close. It is nil for + // the stderr fallback. + cleanup func() +} + +// openScreen opens /dev/tty for the Picker to draw on, falling back to +// stderr when no TTY can be opened. stdout is never used, since a caller +// may pipe it (the Wrapper reads the chosen path from Run's return value, +// not from the program's own output). +func openScreen() screen { in, out, err := tea.OpenTTY() if err != nil { - return []tea.ProgramOption{tea.WithOutput(os.Stderr)}, nil, nil + return screen{ + in: os.Stdin, + out: os.Stderr, + opts: []tea.ProgramOption{tea.WithOutput(os.Stderr)}, + } } - cleanup := func() { - _ = in.Close() - _ = out.Close() + return screen{ + in: in, + out: out, + opts: []tea.ProgramOption{tea.WithInput(in), tea.WithOutput(out)}, + cleanup: func() { + _ = in.Close() + _ = out.Close() + }, } - return []tea.ProgramOption{tea.WithInput(in), tea.WithOutput(out)}, cleanup, nil +} + +// dark reports whether the terminal has a dark background, asked and +// answered before the program starts so the very first frame is already in +// the right palette. Bubble Tea reports the background asynchronously, a +// frame or two in, which repainted the whole Picker on launch. lipgloss +// sends a device-attributes query alongside, so a terminal that ignores the +// background query still ends this one promptly, leaving the palette dark. +func (s screen) dark() bool { + return lipgloss.HasDarkBackground(s.in, s.out) } From d7372dc121ffe65f27f3c2c79a4f4c43616f4427 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:46:03 +0500 Subject: [PATCH 3/5] fix(picker): reserve the status column from the first frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row statuses land one at a time with the Picker already on screen, and the column was sized to whichever of them had arrived: one column wide while the git fan-out was in flight, six once clusters like "✓ ? ↑1" came back, taking the names, the times and the preview's edge two columns right with it. Hold it at "✓ ? ↑1↓1" from the start; glyphs now fill a column that is already their size, and a wider cluster still grows it. Part of #55 Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/layout.go | 15 +++++++++++---- internal/picker/layout_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/internal/picker/layout.go b/internal/picker/layout.go index 0cd0c03..aba67ad 100644 --- a/internal/picker/layout.go +++ b/internal/picker/layout.go @@ -15,14 +15,20 @@ const previewFloor = 30 // caretWidth is the fixed-width caret/gutter column (" " or " › "). const caretWidth = 3 +// statusReserve is the width the status column is held at from the first +// frame, wide enough for "✓ ? ↑1↓1". Row statuses land one at a time while +// the Picker is already on screen, and a column sized to whichever of them +// have arrived would widen under the user as the rest do. +const statusReserve = 8 + // Layout is the Picker's per-frame sizing, recomputed from every // tea.WindowSizeMsg without losing any Model state. type Layout struct { // NameWidth is the column width for a Project name, after any // truncation. NameWidth int - // StatusWidth is the column width for the status glyph cluster. It - // never shrinks. + // StatusWidth is the column width for the status glyph cluster, never + // narrower than statusReserve. StatusWidth int // TimeWidth is the column width for the relative last-visit time. TimeWidth int @@ -55,11 +61,12 @@ type Layout struct { // previewFloor columns for the preview, the preview is dropped and the list // takes the full width. Within the list's own budget, relative time // compresses to its short form first, then names truncate with "…" down to -// nameFloor; the status cluster never shrinks and there is no hard minimum. +// nameFloor; the status cluster is never narrower than statusReserve and +// there is no hard minimum. func ComputeLayout(longestName, widestStatus int, times []time.Time, now time.Time, width, height int) Layout { l := Layout{ NameWidth: longestName, - StatusWidth: widestStatus, + StatusWidth: max(widestStatus, statusReserve), TimeWidth: widestTime(times, now, false), } diff --git a/internal/picker/layout_test.go b/internal/picker/layout_test.go index 81d8651..7477893 100644 --- a/internal/picker/layout_test.go +++ b/internal/picker/layout_test.go @@ -95,3 +95,36 @@ func TestComputeLayout_FooterLines(t *testing.T) { }) } } + +// TestComputeLayout_StatusColumnHoldsItsWidthWhileLoading pins the Layout +// computed while every row still reads "…" to the one computed once real +// clusters have landed. Statuses arrive a row at a time with the Picker +// already on screen, and a column sized to the ones that have arrived +// shifted the names, the times and the preview's edge as the rest did. +func TestComputeLayout_StatusColumnHoldsItsWidthWhileLoading(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + times := []time.Time{now.Add(-2 * time.Hour), now.Add(-3 * 24 * time.Hour)} + + // 1 is the width of the "…" placeholder every row reports before its + // status lands; 6 is "✓ ? ↑1", a loaded cluster. + loading := picker.ComputeLayout(20, 1, times, now, 120, 24) + loaded := picker.ComputeLayout(20, 6, times, now, 120, 24) + + if loading != loaded { + t.Errorf("layout while loading = %+v, want it identical to the loaded layout %+v", loading, loaded) + } +} + +// TestComputeLayout_StatusColumnGrowsPastTheReserve verifies that a cluster +// wider than the reserve still gets its room: the reserve is a floor, not a +// cap. +func TestComputeLayout_StatusColumnGrowsPastTheReserve(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + times := []time.Time{now.Add(-2 * time.Hour)} + + lay := picker.ComputeLayout(20, 12, times, now, 120, 24) + + if lay.StatusWidth != 12 { + t.Errorf("StatusWidth = %d, want 12 (the widest cluster, wider than the reserve)", lay.StatusWidth) + } +} From e452161d7a3b09bbff1d20d8b5b603f9886e4bde Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:47:49 +0500 Subject: [PATCH 4/5] Revert "fix(picker): settle the palette before the program starts" Measured against a terminal that answers neither the background query nor the device-attributes query alongside it: lipgloss waits out its own two-second timeout, and the Picker paints nothing at all until it expires. A two-second blank launch is a worse bug than the frame it was fixing. The next commit holds the first frame on a deadline of the Picker's own instead, which is bounded whatever the terminal does. Part of #55 Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/model.go | 3 +- internal/picker/picker.go | 66 +++++++++++---------------------------- 2 files changed, 20 insertions(+), 49 deletions(-) diff --git a/internal/picker/model.go b/internal/picker/model.go index ba5e14e..4e77132 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -93,8 +93,7 @@ type statusResultMsg struct { // Init fires one command per row that fetches its git status, fanned out // through tea.Batch and bounded by a semaphore so a large History does not // spawn unbounded concurrent git processes. It also requests the terminal -// background colour, which corrects the palette Run already settled on -// should its own synchronous query have gone unanswered. +// background colour, used to pick the light or dark palette. func (m Model) Init() tea.Cmd { sem := make(chan struct{}, concurrency) cmds := make([]tea.Cmd, 0, len(m.rows)+1) diff --git a/internal/picker/picker.go b/internal/picker/picker.go index af75aa1..4de8ffd 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -13,7 +13,6 @@ import ( "time" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/kryft-dev/cdd/internal/git" ) @@ -89,15 +88,17 @@ const concurrency = 8 // The Picker draws on /dev/tty via tea.OpenTTY, falling back to stderr when // no TTY is available. It never writes to stdout. func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { - s := openScreen() - if s.cleanup != nil { - defer s.cleanup() - } - m := NewModel(rows, status, opts) - m.dark = s.dark() - p := tea.NewProgram(m, s.opts...) + ttyOpts, cleanup, err := ttyProgramOptions() + if err != nil { + return Row{}, false, err + } + if cleanup != nil { + defer cleanup() + } + + p := tea.NewProgram(m, ttyOpts...) final, err := p.Run() if err != nil { return Row{}, false, err @@ -113,47 +114,18 @@ func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { return fm.chosenRow, true, nil } -// screen is where the Picker draws: the files it reads from and writes to, -// and the Bubble Tea options pointing the program at them. -type screen struct { - in, out *os.File - opts []tea.ProgramOption - - // cleanup closes the files, when they are ours to close. It is nil for - // the stderr fallback. - cleanup func() -} - -// openScreen opens /dev/tty for the Picker to draw on, falling back to -// stderr when no TTY can be opened. stdout is never used, since a caller -// may pipe it (the Wrapper reads the chosen path from Run's return value, -// not from the program's own output). -func openScreen() screen { +// ttyProgramOptions builds the tea.ProgramOptions that make the Picker draw +// on /dev/tty, falling back to stderr when no TTY can be opened. stdout is +// never used, since a caller may pipe it (the Wrapper reads the chosen path +// from Run's return value, not from the program's own output). +func ttyProgramOptions() ([]tea.ProgramOption, func(), error) { in, out, err := tea.OpenTTY() if err != nil { - return screen{ - in: os.Stdin, - out: os.Stderr, - opts: []tea.ProgramOption{tea.WithOutput(os.Stderr)}, - } + return []tea.ProgramOption{tea.WithOutput(os.Stderr)}, nil, nil } - return screen{ - in: in, - out: out, - opts: []tea.ProgramOption{tea.WithInput(in), tea.WithOutput(out)}, - cleanup: func() { - _ = in.Close() - _ = out.Close() - }, + cleanup := func() { + _ = in.Close() + _ = out.Close() } -} - -// dark reports whether the terminal has a dark background, asked and -// answered before the program starts so the very first frame is already in -// the right palette. Bubble Tea reports the background asynchronously, a -// frame or two in, which repainted the whole Picker on launch. lipgloss -// sends a device-attributes query alongside, so a terminal that ignores the -// background query still ends this one promptly, leaving the palette dark. -func (s screen) dark() bool { - return lipgloss.HasDarkBackground(s.in, s.out) + return []tea.ProgramOption{tea.WithInput(in), tea.WithOutput(out)}, cleanup, nil } From 84dadce36c4358ecf924db969f08e5905441662b Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:48:52 +0500 Subject: [PATCH 5/5] fix(picker): hold the first frame until the palette settles The terminal reports its background a frame or two into the Picker's life, so the frame drawn before it landed was repainted in the other palette right after: the flash on launch. Draw nothing until the report arrives, with a 50ms deadline of the Picker's own so a terminal that never answers gets the dark frame promptly rather than a blank screen. Measured in a pty that answers, one that answers late and one that stays silent: the first paint lands at ~0.1s in all three, against ~0.08s and a full light-palette repaint before. View tests now settle the palette through sizedModel, and the two tests that name the deadline message sit in the package. Closes #55 Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/model.go | 25 +++++++++++++++-- internal/picker/palette_test.go | 50 +++++++++++++++++++++++++++++++++ internal/picker/update.go | 10 +++++-- internal/picker/view.go | 7 +++++ internal/picker/view_test.go | 28 ++++-------------- 5 files changed, 92 insertions(+), 28 deletions(-) create mode 100644 internal/picker/palette_test.go diff --git a/internal/picker/model.go b/internal/picker/model.go index 4e77132..9d13c21 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -2,6 +2,7 @@ package picker import ( "context" + "time" tea "charm.land/bubbletea/v2" "github.com/sahilm/fuzzy" @@ -43,6 +44,11 @@ type Model struct { width, height int dark bool + // paletteSettled reports whether the terminal has had its say about + // its background colour, one way or the other. Nothing is drawn until + // it has. + paletteSettled bool + chosen bool chosenRow Row quitting bool @@ -90,14 +96,27 @@ type statusResultMsg struct { status git.Status } +// paletteDeadline is how long the first frame waits on the terminal's +// background colour before being drawn in the dark palette anyway. A +// terminal answers in a few milliseconds; one that never answers must not +// hold the Picker off the screen. +const paletteDeadline = 50 * time.Millisecond + +// paletteDeadlineMsg says the terminal has had long enough to report its +// background colour. +type paletteDeadlineMsg struct{} + // Init fires one command per row that fetches its git status, fanned out // through tea.Batch and bounded by a semaphore so a large History does not // spawn unbounded concurrent git processes. It also requests the terminal -// background colour, used to pick the light or dark palette. +// background colour, which the first frame waits on, and starts the +// deadline that wait is given. func (m Model) Init() tea.Cmd { sem := make(chan struct{}, concurrency) - cmds := make([]tea.Cmd, 0, len(m.rows)+1) - cmds = append(cmds, tea.RequestBackgroundColor) + cmds := make([]tea.Cmd, 0, len(m.rows)+2) + cmds = append(cmds, tea.RequestBackgroundColor, tea.Tick(paletteDeadline, func(time.Time) tea.Msg { + return paletteDeadlineMsg{} + })) for _, r := range m.rows { cmds = append(cmds, statusCmd(m.status, r.Project.Path, sem)) } diff --git a/internal/picker/palette_test.go b/internal/picker/palette_test.go new file mode 100644 index 0000000..b13ea8a --- /dev/null +++ b/internal/picker/palette_test.go @@ -0,0 +1,50 @@ +package picker + +import ( + "context" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/git" +) + +// paletteModel is a Model sized to a terminal but told nothing yet about +// its background colour: the state the Picker opens in. +func paletteModel() Model { + rows := []Row{{Project: Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}} + m := NewModel(rows, func(context.Context, string) git.Status { return git.Status{} }, Options{}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 110, Height: 30}) + return next.(Model) +} + +// TestModel_View_HoldsTheFrameUntilThePaletteSettles verifies that nothing +// is drawn before the terminal reports its background colour, and that the +// deadline releases the frame regardless. Drawing first and asking after +// meant the whole Picker repainted in the other palette a moment into its +// life, which is the flash a user sees on launch. +func TestModel_View_HoldsTheFrameUntilThePaletteSettles(t *testing.T) { + m := paletteModel() + if content := m.View().Content; content != "" { + t.Errorf("View() before the background report drew %q, want nothing yet", content) + } + + next, _ := m.Update(paletteDeadlineMsg{}) + if content := next.(Model).View().Content; content == "" { + t.Errorf("View() after the deadline drew nothing; a silent terminal must not hold the frame") + } +} + +// TestModel_View_DeadlineDrawsTheDarkFrame pins the frame a silent +// terminal gets to the one a dark terminal gets: dark is the assumption +// lipgloss itself makes, and the one a terminal that will not answer is +// most likely to want. +func TestModel_View_DeadlineDrawsTheDarkFrame(t *testing.T) { + timedOut, _ := paletteModel().Update(paletteDeadlineMsg{}) + reported, _ := paletteModel().Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) + + if silent, dark := timedOut.(Model).View().Content, reported.(Model).View().Content; silent != dark { + t.Errorf("the frame drawn on the deadline is not the dark frame:\ndeadline:\n%s\ndark:\n%s", silent, dark) + } +} diff --git a/internal/picker/update.go b/internal/picker/update.go index b228241..236568c 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -5,8 +5,9 @@ import ( ) // Update handles one message: a key press, a status result landing, a -// terminal resize, or the background colour report. It never blocks and -// never loses state (query, cursor, loaded statuses) across a resize. +// terminal resize, the background colour report, or the deadline the first +// frame gives that report. It never blocks and never loses state (query, +// cursor, loaded statuses) across a resize. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case statusResultMsg: @@ -15,6 +16,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.BackgroundColorMsg: m.dark = msg.IsDark() + m.paletteSettled = true + return m, nil + + case paletteDeadlineMsg: + m.paletteSettled = true return m, nil case tea.WindowSizeMsg: diff --git a/internal/picker/view.go b/internal/picker/view.go index 87db00c..87af3d6 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -18,6 +18,13 @@ func (m Model) View() tea.View { if m.quitting { return tea.NewView("") } + // Nothing is drawn until the terminal has reported its background + // colour or paletteDeadline has passed: a frame drawn in the wrong + // palette repaints in the right one a moment later, which is the + // flash a user sees on launch. + if !m.paletteSettled { + return fullScreen("") + } if len(m.rows) == 0 { return fullScreen(m.emptyHistoryView()) } diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go index 3c18739..3d27159 100644 --- a/internal/picker/view_test.go +++ b/internal/picker/view_test.go @@ -17,11 +17,14 @@ import ( // and windowing the body to the terminal. var bothLayouts = []picker.LayoutStyle{picker.LayoutGrouped, picker.LayoutList} -// sizedModel builds a Model in the given layout and sends it one -// tea.WindowSizeMsg, the state every View test starts from. +// sizedModel builds a Model in the given layout, sends it one +// tea.WindowSizeMsg and reports the terminal's background as dark: a sized +// terminal with a settled palette is the state every View test starts +// from, since nothing is drawn before the palette settles. func sizedModel(rows []picker.Row, layout picker.LayoutStyle, width, height int) picker.Model { m := picker.NewModel(rows, noopStatus, picker.Options{Layout: layout}) next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height}) + next, _ = next.(picker.Model).Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) return next.(picker.Model) } @@ -222,24 +225,3 @@ func TestModel_View_UsesAlternateScreen(t *testing.T) { t.Errorf("empty-History View().AltScreen = false, want true") } } - -// TestModel_View_FirstFrameNeedsNoRepaint pins the frame drawn before the -// terminal has reported its background to the frame drawn after a dark -// report. The reply to that query lands a frame or two in; opening in the -// light palette meant a dark terminal saw one washed-out frame, border box -// and all, flash past on launch. -func TestModel_View_FirstFrameNeedsNoRepaint(t *testing.T) { - for _, layout := range bothLayouts { - t.Run(string(layout), func(t *testing.T) { - m := sizedModel(manyRows(5), layout, 110, 30) - before := m.View().Content - - next, _ := m.Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#0D1117")}) - after := next.(picker.Model).View().Content - - if before != after { - t.Errorf("the first frame is not the dark palette's frame; it repaints when the background report lands:\nbefore:\n%s\nafter:\n%s", before, after) - } - }) - } -}