Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions internal/picker/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}

Expand Down
33 changes: 33 additions & 0 deletions internal/picker/layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
31 changes: 27 additions & 4 deletions internal/picker/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package picker

import (
"context"
"time"

tea "charm.land/bubbletea/v2"
"github.com/sahilm/fuzzy"
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
}

Expand All @@ -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))
}
Expand Down
50 changes: 50 additions & 0 deletions internal/picker/palette_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 8 additions & 3 deletions internal/picker/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions internal/picker/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
7 changes: 5 additions & 2 deletions internal/picker/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading