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) + } +} diff --git a/internal/picker/model.go b/internal/picker/model.go index 8f231a8..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" @@ -42,7 +43,11 @@ type Model struct { width, height int dark bool - themeSet 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 @@ -68,6 +73,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, } } @@ -86,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 15b4459..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,7 +16,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.BackgroundColorMsg: m.dark = msg.IsDark() - m.themeSet = true + 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 c7710e7..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) }