From 703cf5bc3a8f9e5e07d446845976000f4bba88fb Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:24:09 +0500 Subject: [PATCH 01/11] refactor(picker): let a row background carry through glyphs and matches The list layout marks its selected row with a background rather than a caret, so every segment of a row has to be rendered through a base style that carries it. statusCluster gains statusClusterOn, taking that base, and highlightMatches derives its match style from the base it is given instead of building a fresh one. The theme grows the selected row's background and foreground. Nothing renders differently yet: the derived match style is the same accent-bold-underline the old one was. Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/styles.go | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/internal/picker/styles.go b/internal/picker/styles.go index b27a206..151406a 100644 --- a/internal/picker/styles.go +++ b/internal/picker/styles.go @@ -15,6 +15,10 @@ import ( // terminal's own foreground. type theme struct { muted, green, yellow, blue, red, accent, rule color.Color + + // selBg and selFg colour the list layout's selected row, which is + // marked by a background highlight rather than a caret. + selBg, selFg color.Color } // newTheme builds the theme for a light or dark background, using the @@ -30,13 +34,14 @@ func newTheme(dark bool) theme { red: ld(c("#CF222E"), c("#F85149")), accent: ld(c("#8250DF"), c("#A371F7")), rule: ld(c("#D0D7DE"), c("#30363D")), + selBg: ld(c("#DDF4FF"), c("#1F3552")), + selFg: ld(c("#0550AE"), c("#CAE8FF")), } } func (t theme) fg(c color.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } func (t theme) muted_() lipgloss.Style { return t.fg(t.muted) } func (t theme) accentBold() lipgloss.Style { return t.fg(t.accent).Bold(true) } -func (t theme) match() lipgloss.Style { return t.fg(t.accent).Bold(true).Underline(true) } func (t theme) rule_(w int) string { if w < 0 { w = 0 @@ -60,24 +65,33 @@ const ( // statusCluster renders the compact coloured glyph cluster for one row's // status: "✓", "● ?", "● ↑3↓2", "—", "…", "!". func (t theme) statusCluster(st git.Status, loaded bool) string { + return t.statusClusterOn(lipgloss.NewStyle(), st, loaded) +} + +// statusClusterOn renders statusCluster over base, which carries the row's +// background in the list layout so the highlight runs through the glyphs +// and the space between them. +func (t theme) statusClusterOn(base lipgloss.Style, st git.Status, loaded bool) string { + f := func(c color.Color, s string) string { return base.Foreground(c).Render(s) } + if !loaded { - return t.muted_().Render(glyphLoading) + return f(t.muted, glyphLoading) } switch st.Kind { case git.NotRepo: - return t.muted_().Render(glyphNotRepo) + return f(t.muted, glyphNotRepo) case git.Unknown: - return t.fg(t.red).Render(glyphUnknown) + return f(t.red, glyphUnknown) } var parts []string if st.State == git.Dirty { - parts = append(parts, t.fg(t.yellow).Render(glyphModified)) + parts = append(parts, f(t.yellow, glyphModified)) } else { - parts = append(parts, t.fg(t.green).Render(glyphClean)) + parts = append(parts, f(t.green, glyphClean)) } if st.Untracked { - parts = append(parts, t.fg(t.blue).Render(glyphUntracked)) + parts = append(parts, f(t.blue, glyphUntracked)) } sync := "" if st.Ahead > 0 { @@ -87,9 +101,9 @@ func (t theme) statusCluster(st git.Status, loaded bool) string { sync += glyphBehind + strconv.Itoa(st.Behind) } if sync != "" { - parts = append(parts, t.fg(t.accent).Render(sync)) + parts = append(parts, f(t.accent, sync)) } - return strings.Join(parts, " ") + return strings.Join(parts, base.Render(" ")) } // statusClusterWidth is the plain (uncoloured) width of statusCluster's @@ -190,11 +204,14 @@ func previewSync(t theme, st git.Status, loaded bool) string { } // highlightMatches renders s with the rune positions in matched (indexes -// into s, shifted by offset) rendered in the accent match style. +// into s, shifted by offset) rendered in the accent match style. The match +// style is derived from base, so a row background carries through the +// highlighted runes as well as the plain ones. func highlightMatches(s string, matched []int, offset int, base lipgloss.Style, t theme) string { if len(matched) == 0 { return base.Render(s) } + matchStyle := base.Foreground(t.accent).Bold(true).Underline(true) set := make(map[int]bool, len(matched)) for _, i := range matched { set[i-offset] = true @@ -208,7 +225,7 @@ func highlightMatches(s string, matched []int, offset int, base lipgloss.Style, } chunk := string(runes[i:j]) if set[i] { - b.WriteString(t.match().Render(chunk)) + b.WriteString(matchStyle.Render(chunk)) } else { b.WriteString(base.Render(chunk)) } From 69a887862dd533ecdac1f3da871c9b9720e43300 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:24:15 +0500 Subject: [PATCH 02/11] feat(picker): carry the chosen layout on Options and the Model LayoutStyle names the two layouts the Picker can draw, Options.Layout selects one (the zero value staying LayoutGrouped, the accepted look), and the Model remembers it. visibleRows states the row order the cursor indexes into for the active layout: grouped by Kind as before, or flat History order for the list layout. The View still draws the grouped layout either way. Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/model.go | 17 +++++++++++++++++ internal/picker/picker.go | 20 ++++++++++++++++++++ internal/picker/update.go | 4 ++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/picker/model.go b/internal/picker/model.go index 83c60a3..8f231a8 100644 --- a/internal/picker/model.go +++ b/internal/picker/model.go @@ -32,6 +32,7 @@ type Model struct { rows []Row status StatusFunc vim bool + layout LayoutStyle query string focus focus @@ -55,10 +56,15 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model { if opts.Vim { f = focusList } + layout := opts.Layout + if layout == "" { + layout = LayoutGrouped + } return Model{ rows: rows, status: status, vim: opts.Vim, + layout: layout, query: opts.Query, focus: f, statuses: make(map[string]git.Status, len(rows)), @@ -167,6 +173,17 @@ func (m Model) visibleGroups() []kindGroup { return groupByKind(m.visibleMatches()) } +// visibleRows returns the current query's matches in the order the active +// layout draws them: grouped by Kind for LayoutGrouped, flat History order +// for LayoutList. It is the order the cursor indexes into. +func (m Model) visibleRows() []match { + matches := m.visibleMatches() + if m.layout == LayoutList { + return matches + } + return flatten(groupByKind(matches)) +} + // flatten lays a Kind grouping out as a single ordered slice of matches, // matching the row order the list draws (header lines aside). func flatten(groups []kindGroup) []match { diff --git a/internal/picker/picker.go b/internal/picker/picker.go index d13b51d..4de8ffd 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -43,6 +43,22 @@ type Row struct { // without blocking the screen. type StatusFunc func(ctx context.Context, dir string) git.Status +// LayoutStyle names one of the Picker's two layouts. It is a standing +// preference, set once in config.toml, not a per-session toggle. +type LayoutStyle string + +const ( + // LayoutGrouped is the default layout: rows grouped under Kind + // headers, a caret on the selected row, and the filter line on top. + LayoutGrouped LayoutStyle = "grouped" + + // LayoutList is the flat fzf-style layout: no Kind headers, rows in + // History order with "kind/" muted before each Project name, the + // filter prompt below the list, and a bar plus background highlight + // on the selected row. + LayoutList LayoutStyle = "list" +) + // Options configures a Run of the Picker. type Options struct { // Vim selects the vim key map: the list is focused on open, j/k move, @@ -55,6 +71,10 @@ type Options struct { // Query seeds the filter line. Query string + + // Layout selects which layout is drawn. The zero value is + // LayoutGrouped. + Layout LayoutStyle } // concurrency bounds how many StatusFunc calls run at once, so a large diff --git a/internal/picker/update.go b/internal/picker/update.go index 6d06046..15b4459 100644 --- a/internal/picker/update.go +++ b/internal/picker/update.go @@ -136,7 +136,7 @@ func (m *Model) clampCursor() { // lastIndex is the index of the last visible row, or 0 when there are none. func (m Model) lastIndex() int { - n := len(flatten(m.visibleGroups())) + n := len(m.visibleRows()) if n == 0 { return 0 } @@ -155,7 +155,7 @@ func (m *Model) backspace() { // choose selects the row under the cursor, when there is one, and quits. func (m Model) choose() (tea.Model, tea.Cmd) { - rows := flatten(m.visibleGroups()) + rows := m.visibleRows() if m.cursor >= 0 && m.cursor < len(rows) { m.chosen = true m.chosenRow = rows[m.cursor].row From 02bdb5cafd70312590f70b91eeac3f24f7897f4f Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:24:15 +0500 Subject: [PATCH 03/11] feat(picker): draw the flat fzf-style list layout Prototype variant C, from the kept prototype/picker-look branch: a flat list with no Kind headers, "kind/" muted before each Project name, one status cluster per row, the relative time right-aligned, the filter prompt below the list where fzf users expect it, and a bar plus a background highlight on the selected row. View now dispatches on the Model's layout, with the frame size, the column budget, the preview pane and the footer shared between the two. Both layouts spend the same total width on their fixed columns, so ComputeLayout needs no change: the list layout just hands it "kind/name" as the name column's content. Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/view.go | 81 +++++++---- internal/picker/view_list.go | 138 ++++++++++++++++++ internal/picker/view_list_test.go | 226 ++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 28 deletions(-) create mode 100644 internal/picker/view_list.go create mode 100644 internal/picker/view_list_test.go diff --git a/internal/picker/view.go b/internal/picker/view.go index 296db8b..75a31c7 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -11,8 +11,9 @@ import ( "github.com/kryft-dev/cdd/internal/git" ) -// View renders the current frame: the filter line, the grouped list (with a -// preview pane beside it when there is room), and the footer. +// View renders the current frame in the Model's layout: the grouped +// layout by default, the flat fzf-style one when Options.Layout selected +// it. Both share the sizing, the preview pane and the footer. func (m Model) View() tea.View { if m.quitting { return tea.NewView("") @@ -23,33 +24,20 @@ func (m Model) View() tea.View { t := newTheme(m.dark) now := time.Now() - groups := m.visibleGroups() - rows := flatten(groups) - - longestName := 0 - for _, mt := range rows { - if n := len([]rune(mt.row.Project.Name)); n > longestName { - longestName = n - } - } - widestStatus := 1 - times := make([]time.Time, 0, len(rows)) - for _, mt := range rows { - st, loaded := m.statuses[mt.row.Project.Path] - if w := statusClusterWidth(st, loaded); w > widestStatus { - widestStatus = w - } - times = append(times, mt.row.LastVisit) + if m.layout == LayoutList { + return fullScreen(m.listFrame(t, now)) } + return fullScreen(m.groupedFrame(t, now)) +} - width, height := m.width, m.height - if width <= 0 { - width = 80 - } - if height <= 0 { - height = 24 - } - lay := ComputeLayout(longestName, widestStatus, times, now, width, height) +// groupedFrame renders the default layout: the filter line, the list +// grouped under Kind headers (with a preview pane beside it when there is +// room), and the footer. +func (m Model) groupedFrame(t theme, now time.Time) string { + groups := m.visibleGroups() + rows := flatten(groups) + width, height := m.frameSize() + lay := m.computeLayout(rows, now, width, height) var b strings.Builder b.WriteString(m.filterLine(t)) @@ -69,7 +57,44 @@ func (m Model) View() tea.View { b.WriteString("\n") b.WriteString(m.footerView(t, width, len(rows), lay)) - return fullScreen(b.String()) + return b.String() +} + +// frameSize is the terminal size to draw at, standing in a default for +// each dimension until the first tea.WindowSizeMsg lands. +func (m Model) frameSize() (width, height int) { + width, height = m.width, m.height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + return width, height +} + +// computeLayout sizes one frame from the rows it has to show. The name +// column holds the Project name in the grouped layout and "kind/name" in +// the list layout; the two layouts spend the same total width on their +// other columns, so one budget serves both. +func (m Model) computeLayout(rows []match, now time.Time, width, height int) Layout { + longestName, widestStatus := 0, 1 + times := make([]time.Time, 0, len(rows)) + for _, mt := range rows { + n := len([]rune(mt.row.Project.Name)) + if m.layout == LayoutList { + n += len([]rune(mt.row.Project.Kind)) + 1 // "kind/" + } + if n > longestName { + longestName = n + } + st, loaded := m.statuses[mt.row.Project.Path] + if w := statusClusterWidth(st, loaded); w > widestStatus { + widestStatus = w + } + times = append(times, mt.row.LastVisit) + } + return ComputeLayout(longestName, widestStatus, times, now, width, height) } // fullScreen wraps content in a View drawn on the alternate screen. The diff --git a/internal/picker/view_list.go b/internal/picker/view_list.go new file mode 100644 index 0000000..092cdb0 --- /dev/null +++ b/internal/picker/view_list.go @@ -0,0 +1,138 @@ +package picker + +import ( + "strings" + "time" + + "charm.land/lipgloss/v2" +) + +// selBar marks the selected row in the list layout, standing in for the +// grouped layout's caret. +const selBar = "▌" + +// listFrame renders the flat fzf-style layout: rows in the order they were +// given (History order, never-visited Projects last) with no Kind headers, +// the filter prompt below them where fzf users expect it, and the shared +// preview pane and footer. +func (m Model) listFrame(t theme, now time.Time) string { + rows := m.visibleMatches() + width, height := m.frameSize() + lay := m.computeLayout(rows, now, width, height) + + var b strings.Builder + body := m.listBody(t, rows, lay, now) + if lay.ShowPreview { + // The preview box is exactly ListHeight lines tall, matching the + // body, so the frame stays at the terminal height. + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, body, " ", m.previewView(t, rows, lay, now))) + } else { + b.WriteString(body) + } + + b.WriteString("\n") + b.WriteString(m.filterLine(t)) + b.WriteString("\n") + b.WriteString(m.footerView(t, width, len(rows), lay)) + + return b.String() +} + +// listBody renders the list pane: one line per visible row, windowed to +// exactly Layout.ListHeight lines and scrolled to keep the cursor's row on +// screen. +func (m Model) listBody(t theme, rows []match, lay Layout, now time.Time) string { + lines := make([]string, 0, max(len(rows), 1)) + for i, mt := range rows { + lines = append(lines, m.listRowView(t, mt, i == m.cursor, lay, now)) + } + if len(rows) == 0 { + lines = append(lines, t.muted_().Render("no projects match")) + } + + listH := max(lay.ListHeight, 1) + start := 0 + if m.cursor >= listH { + start = m.cursor - listH + 1 + } + windowed := make([]string, listH) + for i := range windowed { + if idx := start + i; idx < len(lines) { + windowed[i] = lines[idx] + } + } + return strings.Join(windowed, "\n") +} + +// listRowView renders one row as "▌ STATUS kind/NAME LAST VISIT", padded +// out to Layout.ListWidth. Every segment, padding included, is rendered +// through base, so the selected row's background highlight runs unbroken +// to the edge of the pane. +func (m Model) listRowView(t theme, mt match, selected bool, lay Layout, now time.Time) string { + base := lipgloss.NewStyle() + nameStyle := base + bar := base.Render(" ") + if selected { + base = base.Background(t.selBg) + nameStyle = base.Foreground(t.selFg).Bold(true) + bar = base.Foreground(t.blue).Render(selBar) + } + kindStyle := base.Foreground(t.muted) + + st, loaded := m.statuses[mt.row.Project.Path] + status := padRightOn(base, t.statusClusterOn(base, st, loaded), lay.StatusWidth) + name := m.listNameField(t, mt, lay, base, kindStyle, nameStyle) + + rel := RelativeTime(mt.row.LastVisit, now) + if lay.ShortTime { + rel = RelativeTimeShort(mt.row.LastVisit, now) + } + rel = padLeftOn(base, base.Foreground(t.muted).Render(rel), lay.TimeWidth) + + lead := bar + base.Render(" ") + status + base.Render(" ") + name + gap := max(lay.ListWidth-lipgloss.Width(lead)-lipgloss.Width(rel)-1, 1) + return lead + base.Render(strings.Repeat(" ", gap)) + rel + base.Render(" ") +} + +// listNameField renders "kind/name" padded to Layout.NameWidth, with the +// Kind muted ahead of the Project name and any fuzzy-match runes +// highlighted. When the pair is too wide the Project name is truncated, +// never the Kind, unless the Kind alone already fills the column. +func (m Model) listNameField(t theme, mt match, lay Layout, base, kindStyle, nameStyle lipgloss.Style) string { + p := mt.row.Project + kind := p.Kind + "/" + name := p.Name + + // Matched indexes are rune offsets into the Project's path, of which + // "kind/name" is the tail; shift them onto each segment. + pathLen := len([]rune(p.Path)) + kindOffset := pathLen - len([]rune(kind)) - len([]rune(name)) + nameOffset := pathLen - len([]rune(name)) + + nameWidth := lay.NameWidth - len([]rune(kind)) + if nameWidth < 1 { + field := truncateName(kind+name, lay.NameWidth) + return padRightOn(base, highlightMatches(field, mt.matches, kindOffset, kindStyle, t), lay.NameWidth) + } + name = truncateName(name, nameWidth) + + field := highlightMatches(kind, mt.matches, kindOffset, kindStyle, t) + + highlightMatches(name, mt.matches, nameOffset, nameStyle, t) + return padRightOn(base, field, lay.NameWidth) +} + +// padRightOn and padLeftOn pad a styled string to a display width, putting +// the padding through base so a row background covers it too. +func padRightOn(base lipgloss.Style, s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return s + base.Render(strings.Repeat(" ", d)) + } + return s +} + +func padLeftOn(base lipgloss.Style, s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return base.Render(strings.Repeat(" ", d)) + s + } + return s +} diff --git a/internal/picker/view_list_test.go b/internal/picker/view_list_test.go new file mode 100644 index 0000000..8fa2dd6 --- /dev/null +++ b/internal/picker/view_list_test.go @@ -0,0 +1,226 @@ +package picker_test + +import ( + "regexp" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/picker" +) + +// ansi matches the SGR escape sequences lipgloss wraps each styled +// segment in. A row's text is split across several of them, so assertions +// about what a line reads strip them first. +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// plain is s with its styling escapes removed, as the terminal shows it. +func plain(s string) string { return ansi.ReplaceAllString(s, "") } + +// listModel builds a Model in the flat list layout, sized to width x +// height, over rows. +func listModel(rows []picker.Row, width, height int) picker.Model { + m := picker.NewModel(rows, noopStatus, picker.Options{Layout: picker.LayoutList}) + next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height}) + return next.(picker.Model) +} + +// selBarGlyph is the bar the list layout draws on the selected row. +const selBarGlyph = "▌" + +// TestOptions_LayoutDefaultsToGrouped pins the zero Options to the grouped +// layout, so a caller that says nothing keeps the accepted look. +func TestOptions_LayoutDefaultsToGrouped(t *testing.T) { + m := listModelWithLayout(t, "") + lines := strings.Split(plain(m.View().Content), "\n") + if !strings.Contains(lines[0], "type to filter") { + t.Errorf("first line = %q, want the grouped layout's filter line on top", lines[0]) + } +} + +// listModelWithLayout builds a sized Model over rowsForKindOrder under the +// named layout. +func listModelWithLayout(t *testing.T, layout picker.LayoutStyle) picker.Model { + t.Helper() + m := picker.NewModel(rowsForKindOrder(), noopStatus, picker.Options{Layout: layout}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24}) + return next.(picker.Model) +} + +// TestModel_ListLayout_FlatHistoryOrder verifies that the list layout walks +// rows in the order they were given (History order, never-visited last) +// rather than regrouping them under their Kinds. +func TestModel_ListLayout_FlatHistoryOrder(t *testing.T) { + m := picker.NewModel(rowsForKindOrder(), fakeStatus, picker.Options{Layout: picker.LayoutList}) + + want := []string{ + "/root/tools/cdd", + "/root/work/api", + "/root/oss/lib", + "/root/tools/dotfiles", + } + for i, wantPath := range want { + got := chosenAt(t, m, i) + if got.Project.Path != wantPath { + t.Errorf("row %d = %q, want %q", i, got.Project.Path, wantPath) + } + } +} + +// TestModel_ListLayout_StartsWithARowNotAHeader verifies that the frame +// opens on the first Project row: no filter line above it and no Kind +// header taking a line. +func TestModel_ListLayout_StartsWithARowNotAHeader(t *testing.T) { + m := listModel(rowsForKindOrder(), 120, 24) + lines := strings.Split(plain(m.View().Content), "\n") + + if !strings.Contains(lines[0], "tools/cdd") { + t.Errorf("first line = %q, want it to be the tools/cdd row", lines[0]) + } + if strings.Contains(lines[0], "type to filter") { + t.Errorf("first line = %q, want the filter prompt below the list, not above it", lines[0]) + } +} + +// TestModel_ListLayout_PromptBelowList pins the filter prompt to the line +// directly below the list body, where fzf users expect it, with the footer +// underneath. +func TestModel_ListLayout_PromptBelowList(t *testing.T) { + rows := rowsForKindOrder() + m := listModel(rows, 120, 24) + lay := picker.ComputeLayout(20, 1, make([]time.Time, len(rows)), time.Now(), 120, 24) + + lines := strings.Split(plain(m.View().Content), "\n") + prompt := lines[lay.ListHeight] + if !strings.Contains(prompt, "type to filter") { + t.Errorf("line %d = %q, want the filter prompt directly below the list", lay.ListHeight, prompt) + } + + sawKeys := false + for _, l := range lines[lay.ListHeight+1:] { + if strings.Contains(l, "move") && strings.Contains(l, "jump") { + sawKeys = true + } + } + if !sawKeys { + t.Errorf("footer keys line is missing below the prompt:\n%s", m.View().Content) + } +} + +// TestModel_ListLayout_KindPrefixAndPreview verifies a row shows "kind/" +// before the Project name and that the shared preview pane is still drawn. +func TestModel_ListLayout_KindPrefixAndPreview(t *testing.T) { + m := listModel(rowsForKindOrder(), 120, 24) + out := plain(m.View().Content) + + if !strings.Contains(out, "work/api") { + t.Errorf("View() output is missing the %q row:\n%s", "work/api", out) + } + if !strings.ContainsAny(out, "╭╮╰╯") { + t.Errorf("View() output is missing the preview box:\n%s", out) + } +} + +// TestModel_ListLayout_SelectedRowCarriesBar verifies the selected row is +// marked with the "▌" bar, and only the selected row. +func TestModel_ListLayout_SelectedRowCarriesBar(t *testing.T) { + m := listModel(rowsForKindOrder(), 120, 24) + + if n := strings.Count(plain(m.View().Content), selBarGlyph); n != 1 { + t.Errorf("View() drew %d %q bars, want exactly 1 (the selected row)", n, selBarGlyph) + } + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(picker.Model) + for _, l := range strings.Split(plain(m.View().Content), "\n") { + if strings.Contains(l, selBarGlyph) && !strings.Contains(l, "work/api") { + t.Errorf("after one move down the bar is on %q, want it on the work/api row", l) + } + } +} + +// TestModel_ListLayout_RowsShareEqualWidth verifies every list row renders +// to the same display width, so the selected row's background highlight +// spans the pane instead of stopping at the text. +func TestModel_ListLayout_RowsShareEqualWidth(t *testing.T) { + rows := rowsForKindOrder() + m := listModel(rows, 40, 24) // narrow: no preview pane, names appear once + + var widths []int + for _, l := range strings.Split(m.View().Content, "\n") { + for _, r := range rows { + if strings.Contains(plain(l), r.Project.Kind+"/"+r.Project.Name) { + widths = append(widths, lipgloss.Width(l)) + } + } + } + if len(widths) != len(rows) { + t.Fatalf("found %d row lines, want %d", len(widths), len(rows)) + } + for i := 1; i < len(widths); i++ { + if widths[i] != widths[0] { + t.Errorf("row %d width = %d, want %d (same as row 0, selected or not)", i, widths[i], widths[0]) + } + } +} + +// TestModel_ListLayout_FrameMatchesTerminalHeight pins the list layout's +// frame to exactly the terminal height, at every size the grouped layout +// is pinned at. +func TestModel_ListLayout_FrameMatchesTerminalHeight(t *testing.T) { + rows := manyRows(11) + for _, width := range []int{110, 45} { + for _, height := range []int{40, 30, 24, 14, 9} { + m := listModel(rows, width, height) + lines := strings.Split(m.View().Content, "\n") + if len(lines) != height { + t.Errorf("View() at %dx%d produced %d lines, want exactly %d", width, height, len(lines), height) + } + } + } +} + +// TestModel_ListLayout_ScrollsCursorIntoView verifies a list far taller +// than the terminal scrolls so the cursor's row stays drawn. +func TestModel_ListLayout_ScrollsCursorIntoView(t *testing.T) { + rows := manyRows(40) + m := listModel(rows, 120, 20) + + for i := 0; i < len(rows)-1; i++ { + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(picker.Model) + } + last := rows[len(rows)-1].Project.Name + if !strings.Contains(plain(m.View().Content), last) { + t.Errorf("after moving the cursor to the last row, View() output does not contain %q", last) + } +} + +// TestModel_ListLayout_NoMatches shows the shared empty-filter message +// rather than an empty pane. +func TestModel_ListLayout_NoMatches(t *testing.T) { + m := listModel(rowsForKindOrder(), 120, 24) + for _, r := range "zzzzzz" { + next, _ := m.Update(tea.KeyPressMsg{Text: string(r)}) + m = next.(picker.Model) + } + out := plain(m.View().Content) + if !strings.Contains(out, "no projects match") { + t.Errorf("View() with no matches is missing the empty message:\n%s", out) + } + if !strings.Contains(out, "nothing selected") { + t.Errorf("View() with no matches is missing the empty preview:\n%s", out) + } +} + +// TestModel_ListLayout_UsesAlternateScreen pins the list layout to the +// alternate screen, like the grouped one. +func TestModel_ListLayout_UsesAlternateScreen(t *testing.T) { + m := listModel(manyRows(3), 100, 30) + if !m.View().AltScreen { + t.Errorf("View().AltScreen = false, want true") + } +} From 68bbbc0cebd1205234e9c828d74ff6973a1ae0b2 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:24:19 +0500 Subject: [PATCH 04/11] feat(config): add the picker.layout key [picker] layout selects which layout the Picker draws: "grouped", the default and the look accepted in the prototype review, or "list". Any other value is a config error naming the two it accepts, matching how every other key is validated. Co-Authored-By: Claude Opus 5 (1M context) --- internal/config/config.go | 41 ++++++++++++++++++++++++++++++++-- internal/config/config_test.go | 29 +++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index daba832..00817c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,6 @@ // Package config loads cdd's configuration: the Root to search for Kinds // and Projects, Exclude globs, hidden-directory handling, and the History -// and Picker key settings. +// and Picker settings. package config import ( @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + "slices" + "strconv" "strings" toml "github.com/pelletier/go-toml/v2" @@ -33,6 +35,9 @@ type Config struct { // Keys configures the Picker's key map. Keys Keys `toml:"keys"` + + // Picker configures the Picker's appearance. + Picker Picker `toml:"picker"` } // History configures cdd's History of Visits. @@ -50,6 +55,20 @@ type Keys struct { Vim bool `toml:"vim"` } +// Picker configures the Picker's appearance. +type Picker struct { + // Layout selects which layout the Picker draws: "grouped" (the + // default) groups rows under Kind headers with a caret on the + // selected row; "list" is a flat fzf-style list with the filter + // prompt below it and a background-highlighted selected row. Both + // draw the same preview pane. + Layout string `toml:"layout"` +} + +// pickerLayouts are the values Picker.Layout accepts, in the order the +// error message lists them. +var pickerLayouts = []string{"grouped", "list"} + // defaultConfig returns a Config with every default applied, before a // config.toml's fields are decoded on top of it. func defaultConfig() Config { @@ -58,6 +77,7 @@ func defaultConfig() Config { IncludeHidden: false, History: History{MaxVisits: 1000}, Keys: Keys{Vim: false}, + Picker: Picker{Layout: "grouped"}, } } @@ -70,6 +90,8 @@ include_hidden = false max_visits = 1000 # must be >= 1 [keys] vim = false +[picker] +layout = "grouped" # or "list" for the flat fzf-style layout ` // ExampleConfig returns an example config.toml, for the cli package to print @@ -143,7 +165,8 @@ func describeDecodeError(err error) error { // validate checks the decoded Config against the rules Load and LoadFrom // enforce: Root is required, expanded, and must be an existing directory; -// History.MaxVisits must be at least 1. +// History.MaxVisits must be at least 1; Picker.Layout must name a known +// layout. func (c *Config) validate(path string) error { if c.Root == "" { return fmt.Errorf("config: %s: root is required\n\nExample config.toml:\n\n%s", path, exampleConfigBody) @@ -170,9 +193,23 @@ func (c *Config) validate(path string) error { return fmt.Errorf("config: %s: history.max_visits must be >= 1, got %d", path, c.History.MaxVisits) } + if !slices.Contains(pickerLayouts, c.Picker.Layout) { + return fmt.Errorf("config: %s: picker.layout must be one of %s, got %q", path, quoteList(pickerLayouts), c.Picker.Layout) + } + return nil } +// quoteList renders values as a quoted, comma-separated list, for the +// error naming the layouts picker.layout accepts. +func quoteList(values []string) string { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = strconv.Quote(v) + } + return strings.Join(quoted, ", ") +} + // expandHome expands a leading "~" in path to the user's home directory. // "$VAR" references are left as-is. func expandHome(path string) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9290550..99460fb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -60,6 +60,9 @@ func TestLoadFromMinimalAppliesDefaults(t *testing.T) { if cfg.Keys.Vim { t.Errorf("Keys.Vim = true, want false") } + if cfg.Picker.Layout != "grouped" { + t.Errorf("Picker.Layout = %q, want %q", cfg.Picker.Layout, "grouped") + } } func TestLoadFromEachKey(t *testing.T) { @@ -71,6 +74,8 @@ include_hidden = true max_visits = 42 [keys] vim = true +[picker] +layout = "list" ` path := writeConfig(t, body) @@ -100,6 +105,9 @@ vim = true if !cfg.Keys.Vim { t.Errorf("Keys.Vim = false, want true") } + if cfg.Picker.Layout != "list" { + t.Errorf("Picker.Layout = %q, want %q", cfg.Picker.Layout, "list") + } } func TestLoadFromTildeExpansion(t *testing.T) { @@ -154,6 +162,25 @@ max_visits = 0 } } +func TestLoadFromUnknownPickerLayoutRejected(t *testing.T) { + root := t.TempDir() + body := `root = "` + root + `" +[picker] +layout = "fancy" +` + path := writeConfig(t, body) + + _, err := config.LoadFrom(path) + if err == nil { + t.Fatal(`LoadFrom layout = "fancy": got nil error, want error`) + } + for _, want := range []string{"picker.layout", "fancy", "grouped", "list"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, want it to contain %q", err.Error(), want) + } + } +} + func TestLoadFromRootNotDirectoryRejected(t *testing.T) { file, err := os.CreateTemp(t.TempDir(), "root-*") if err != nil { @@ -192,7 +219,7 @@ func TestLoadFromRootMissing(t *testing.T) { func TestExampleConfig(t *testing.T) { got := config.ExampleConfig() - for _, want := range []string{"root =", "exclude =", "include_hidden =", "[history]", "max_visits =", "[keys]", "vim ="} { + for _, want := range []string{"root =", "exclude =", "include_hidden =", "[history]", "max_visits =", "[keys]", "vim =", "[picker]", "layout ="} { if !strings.Contains(got, want) { t.Errorf("ExampleConfig() = %q, want it to contain %q", got, want) } From 9d625d61e20081c5734b803f5906e1c58d578c39 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:24:19 +0500 Subject: [PATCH 05/11] feat(jump): open the Picker in the configured layout Resolve already forwards the vim key map; it now forwards picker.layout alongside it, so the config key reaches the Picker. Co-Authored-By: Claude Opus 5 (1M context) --- internal/jump/jump.go | 2 +- internal/jump/resolve_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/jump/jump.go b/internal/jump/jump.go index 52f3c84..7807f53 100644 --- a/internal/jump/jump.go +++ b/internal/jump/jump.go @@ -76,7 +76,7 @@ func choose(cfg config.Config, projects []project.Project, latest []history.Visi return s } - row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Query: query}) + row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Query: query, Layout: picker.LayoutStyle(cfg.Picker.Layout)}) if err != nil { return "", "", fmt.Errorf("jump: %w", err) } diff --git a/internal/jump/resolve_test.go b/internal/jump/resolve_test.go index a948968..a4e762a 100644 --- a/internal/jump/resolve_test.go +++ b/internal/jump/resolve_test.go @@ -118,6 +118,34 @@ func TestResolve_AmbiguousNameOpensPickerPrefilled(t *testing.T) { assertRecorded(t, hist, "tools/cdd") } +// TestResolve_ForwardsPickerOptions verifies that the config keys that +// configure the Picker reach it: the vim key map and the layout. +func TestResolve_ForwardsPickerOptions(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + cfg.Keys.Vim = true + cfg.Picker.Layout = "list" + hist := newHistory(t) + + var got picker.Options + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + got = opts + return picker.Row{Project: picker.Project{ + Kind: "tools", Name: "cdd", Path: filepath.Join(root, "tools", "cdd"), + }}, true, nil + } + + if _, err := jump.Resolve(context.Background(), cfg, hist, "", pick); err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + if !got.Vim { + t.Errorf("Options.Vim = false, want true") + } + if got.Layout != picker.LayoutList { + t.Errorf("Options.Layout = %q, want %q", got.Layout, picker.LayoutList) + } +} + func TestResolve_NoMatchOpensPickerPrefilled(t *testing.T) { cfg, root := mkProjects(t, "tools/cdd") hist := newHistory(t) From 07dd6d13f8c4febb1d4817377a6550cb90236aef Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:24:19 +0500 Subject: [PATCH 06/11] docs(readme): document picker.layout and the list layout Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 78908c9..115e51b 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,21 @@ Vim key map (`keys.vim = true`): the list is focused on open. | `esc` / `q` | cancel | | `enter` | Jump to the selected Project | +### Layout + +The Picker ships two layouts, selected with `picker.layout`. + +`grouped` (the default) groups rows under Kind headers, marks the selected +row with a caret, and puts the filter line above the list. + +`list` is a flat fzf-style list: no Kind headers, rows in History order +with never-visited Projects last, `kind/` muted before each Project name, +the filter prompt below the list, and a `▌` bar plus a background +highlight on the selected row. + +Both draw the same preview pane, use the same keys, status glyphs and +colours, and degrade the same way on a narrow terminal. + ## Config reference `cdd` reads `config.toml` from `$XDG_CONFIG_HOME/cdd/config.toml`, falling @@ -107,6 +122,8 @@ include_hidden = false max_visits = 1000 # must be >= 1 [keys] vim = false +[picker] +layout = "grouped" # or "list" for the flat fzf-style layout ``` - `root` (required): the top-level directory whose Kinds are searched for @@ -121,6 +138,9 @@ vim = false - `[keys].vim`: when `true`, the Picker opens with the list focused and uses the vim key map described above. Defaults to `false`, the default key map. +- `[picker].layout`: which Picker layout to draw, `"grouped"` (the + default) or `"list"`, as described above. Any other value is a config + error. History is stored at `$XDG_DATA_HOME/cdd/history`, falling back to `~/.local/share/cdd/history` when `XDG_DATA_HOME` is unset. From 8dda3c43a95966ebdbc4a6e474a715360a89b3e9 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:26:54 +0500 Subject: [PATCH 07/11] fix(picker): keep the Project name legible under a long Kind The name column gave the Kind all the room it asked for and truncated the Project name with whatever was left, so a Kind long enough to fill the column cut the name down to a rune or two and rendered it muted along with the Kind. The name is what the user is reading for: it now keeps a floor of its own and the Kind is the part that gives way, each segment still carrying its own style. listFrame also draws m.visibleRows() rather than m.visibleMatches(), the same order the cursor and choose() index, so the drawn order and the chosen Row cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/view_list.go | 22 +++++++++------ internal/picker/view_list_test.go | 45 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/internal/picker/view_list.go b/internal/picker/view_list.go index 092cdb0..670d7a3 100644 --- a/internal/picker/view_list.go +++ b/internal/picker/view_list.go @@ -16,7 +16,7 @@ const selBar = "▌" // the filter prompt below them where fzf users expect it, and the shared // preview pane and footer. func (m Model) listFrame(t theme, now time.Time) string { - rows := m.visibleMatches() + rows := m.visibleRows() width, height := m.frameSize() lay := m.computeLayout(rows, now, width, height) @@ -94,10 +94,16 @@ func (m Model) listRowView(t theme, mt match, selected bool, lay Layout, now tim return lead + base.Render(strings.Repeat(" ", gap)) + rel + base.Render(" ") } +// listNameFloor is the least room the Project name keeps in the name +// column. A Kind long enough to crowd it out is truncated instead: the +// name is what the user is reading for. +const listNameFloor = 4 + // listNameField renders "kind/name" padded to Layout.NameWidth, with the // Kind muted ahead of the Project name and any fuzzy-match runes -// highlighted. When the pair is too wide the Project name is truncated, -// never the Kind, unless the Kind alone already fills the column. +// highlighted. The Project name is truncated first when the pair is too +// wide, and the Kind once the name is down to listNameFloor. Each segment +// keeps its own style, so a long Kind never mutes the name with it. func (m Model) listNameField(t theme, mt match, lay Layout, base, kindStyle, nameStyle lipgloss.Style) string { p := mt.row.Project kind := p.Kind + "/" @@ -109,12 +115,12 @@ func (m Model) listNameField(t theme, mt match, lay Layout, base, kindStyle, nam kindOffset := pathLen - len([]rune(kind)) - len([]rune(name)) nameOffset := pathLen - len([]rune(name)) - nameWidth := lay.NameWidth - len([]rune(kind)) - if nameWidth < 1 { - field := truncateName(kind+name, lay.NameWidth) - return padRightOn(base, highlightMatches(field, mt.matches, kindOffset, kindStyle, t), lay.NameWidth) + kindWidth := len([]rune(kind)) + if over := kindWidth - (lay.NameWidth - listNameFloor); over > 0 { + kindWidth = max(kindWidth-over, 0) + kind = truncateName(kind, kindWidth) } - name = truncateName(name, nameWidth) + name = truncateName(name, max(lay.NameWidth-kindWidth, 0)) field := highlightMatches(kind, mt.matches, kindOffset, kindStyle, t) + highlightMatches(name, mt.matches, nameOffset, nameStyle, t) diff --git a/internal/picker/view_list_test.go b/internal/picker/view_list_test.go index 8fa2dd6..bff1070 100644 --- a/internal/picker/view_list_test.go +++ b/internal/picker/view_list_test.go @@ -167,6 +167,51 @@ func TestModel_ListLayout_RowsShareEqualWidth(t *testing.T) { } } +// TestModel_ListLayout_LongKindKeepsTheName verifies that a Kind too long +// for the name column is the part that gives way: the Project name stays +// on screen, and it keeps its own styling rather than being muted along +// with the Kind. +func TestModel_ListLayout_LongKindKeepsTheName(t *testing.T) { + rows := []picker.Row{ + {Project: picker.Project{ + Kind: "infrastructure-platform", + Name: "obs", + Path: "/root/infrastructure-platform/obs", + }}, + } + m := listModel(rows, 34, 12) // narrow enough that "kind/" alone overruns + + lines := strings.Split(m.View().Content, "\n") + row := lines[0] + if !strings.Contains(plain(row), "obs") { + t.Fatalf("row = %q, want the Project name %q still drawn", plain(row), "obs") + } + + // The Kind is muted and the selected row's name is not: the two + // segments must not share one styling run. + name := styleOf(row, "obs") + kind := styleOf(row, "infra") + if name == "" { + t.Fatalf("row = %q, could not find a styling run around the name", row) + } + if name == kind { + t.Errorf("name and Kind share the styling run %q, want the Kind muted and the name not", name) + } +} + +// styleOf returns the SGR escape introducing the run of styled text that +// contains want, or "" when want is not found in a styled run. +func styleOf(line, want string) string { + for _, run := range strings.Split(line, "\x1b[m") { + if i := strings.LastIndex(run, "m"); i >= 0 && strings.Contains(run[i:], want) { + if j := strings.Index(run, "\x1b["); j >= 0 { + return run[j : i+1] + } + } + } + return "" +} + // TestModel_ListLayout_FrameMatchesTerminalHeight pins the list layout's // frame to exactly the terminal height, at every size the grouped layout // is pinned at. From ecb8de3791e294bcd33dd7dfd64b81a90e4d3e34 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:28:31 +0500 Subject: [PATCH 08/11] refactor(picker): share the frame sizing, window clipping and row padding The two layouts had grown three copies of the same shapes: the frameSize/computeLayout pair, the block that clips a body to ListHeight and scrolls the cursor into it, and a plain and a base-styled pair of padding helpers. Sizing and clipping move to layout.go, where the rest of the per-frame geometry lives; the padding helpers collapse onto the base-styled pair in styles.go, with the grouped layout passing the zero style as statusCluster already does. view.go drops from 265 lines to 202. listView is renamed groupedBody: it draws the grouped body, and the name read as the list layout's once that layout existed. Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/layout.go | 61 +++++++++++++++++++++++++- internal/picker/styles.go | 24 +++++++++- internal/picker/view.go | 85 +++++------------------------------- internal/picker/view_list.go | 29 +----------- 4 files changed, 95 insertions(+), 104 deletions(-) diff --git a/internal/picker/layout.go b/internal/picker/layout.go index 470b333..0cd0c03 100644 --- a/internal/picker/layout.go +++ b/internal/picker/layout.go @@ -1,6 +1,9 @@ package picker -import "time" +import ( + "strings" + "time" +) // nameFloor is the smallest a truncated Project name is ever shrunk to. const nameFloor = 8 @@ -103,6 +106,62 @@ func ComputeLayout(longestName, widestStatus int, times []time.Time, now time.Ti return l } +// frameSize is the terminal size to draw at, standing in a default for +// each dimension until the first tea.WindowSizeMsg lands. +func (m Model) frameSize() (width, height int) { + width, height = m.width, m.height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + return width, height +} + +// computeLayout sizes one frame from the rows it has to show. The name +// column holds the Project name in the grouped layout and "kind/name" in +// the list layout; the two layouts spend the same total width on their +// other columns, so one budget serves both. +func (m Model) computeLayout(rows []match, now time.Time, width, height int) Layout { + longestName, widestStatus := 0, 1 + times := make([]time.Time, 0, len(rows)) + for _, mt := range rows { + n := len([]rune(mt.row.Project.Name)) + if m.layout == LayoutList { + n += len([]rune(mt.row.Project.Kind)) + 1 // "kind/" + } + if n > longestName { + longestName = n + } + st, loaded := m.statuses[mt.row.Project.Path] + if w := statusClusterWidth(st, loaded); w > widestStatus { + widestStatus = w + } + times = append(times, mt.row.LastVisit) + } + return ComputeLayout(longestName, widestStatus, times, now, width, height) +} + +// window clips lines to exactly height lines, scrolled just far enough to +// keep cursorLine on screen and padded with blanks when lines run short. +// Both layouts draw their body through it, so the frame always comes out +// at the terminal height. +func window(lines []string, cursorLine, height int) string { + height = max(height, 1) + start := 0 + if cursorLine >= height { + start = cursorLine - height + 1 + } + out := make([]string, height) + for i := range out { + if idx := start + i; idx < len(lines) { + out[i] = lines[idx] + } + } + return strings.Join(out, "\n") +} + // widestTime is the widest rendered relative time among times, using the // short form when short is true. func widestTime(times []time.Time, now time.Time, short bool) int { diff --git a/internal/picker/styles.go b/internal/picker/styles.go index 151406a..b65e795 100644 --- a/internal/picker/styles.go +++ b/internal/picker/styles.go @@ -49,6 +49,28 @@ func (t theme) rule_(w int) string { return t.fg(t.rule).Render(strings.Repeat("─", w)) } +// plainStyle is the zero style: the base a layout passes when its rows +// carry no background of their own. +var plainStyle = lipgloss.NewStyle() + +// padRightOn and padLeftOn pad a plain or styled string to a display +// width, putting the padding through base so a row background covers it +// too. The grouped layout passes plainStyle; the list layout passes the +// selected row's background. +func padRightOn(base lipgloss.Style, s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return s + base.Render(strings.Repeat(" ", d)) + } + return s +} + +func padLeftOn(base lipgloss.Style, s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return base.Render(strings.Repeat(" ", d)) + s + } + return s +} + // Status glyphs, shared by the row cluster, the preview pane and the // legend. const ( @@ -65,7 +87,7 @@ const ( // statusCluster renders the compact coloured glyph cluster for one row's // status: "✓", "● ?", "● ↑3↓2", "—", "…", "!". func (t theme) statusCluster(st git.Status, loaded bool) string { - return t.statusClusterOn(lipgloss.NewStyle(), st, loaded) + return t.statusClusterOn(plainStyle, st, loaded) } // statusClusterOn renders statusCluster over base, which carries the row's diff --git a/internal/picker/view.go b/internal/picker/view.go index 75a31c7..87db00c 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -43,7 +43,7 @@ func (m Model) groupedFrame(t theme, now time.Time) string { b.WriteString(m.filterLine(t)) b.WriteString("\n") - list := m.listView(t, groups, rows, lay, now) + list := m.groupedBody(t, groups, rows, lay, now) if lay.ShowPreview { // The preview box is exactly ListHeight lines tall (lipgloss v2 // counts the border in Height), so joining it at the top keeps @@ -60,43 +60,6 @@ func (m Model) groupedFrame(t theme, now time.Time) string { return b.String() } -// frameSize is the terminal size to draw at, standing in a default for -// each dimension until the first tea.WindowSizeMsg lands. -func (m Model) frameSize() (width, height int) { - width, height = m.width, m.height - if width <= 0 { - width = 80 - } - if height <= 0 { - height = 24 - } - return width, height -} - -// computeLayout sizes one frame from the rows it has to show. The name -// column holds the Project name in the grouped layout and "kind/name" in -// the list layout; the two layouts spend the same total width on their -// other columns, so one budget serves both. -func (m Model) computeLayout(rows []match, now time.Time, width, height int) Layout { - longestName, widestStatus := 0, 1 - times := make([]time.Time, 0, len(rows)) - for _, mt := range rows { - n := len([]rune(mt.row.Project.Name)) - if m.layout == LayoutList { - n += len([]rune(mt.row.Project.Kind)) + 1 // "kind/" - } - if n > longestName { - longestName = n - } - st, loaded := m.statuses[mt.row.Project.Path] - if w := statusClusterWidth(st, loaded); w > widestStatus { - widestStatus = w - } - times = append(times, mt.row.LastVisit) - } - return ComputeLayout(longestName, widestStatus, times, now, width, height) -} - // fullScreen wraps content in a View drawn on the alternate screen. The // frame always fills the terminal, and the alternate screen guarantees // the shell's own scrollback comes back untouched when the Picker exits; @@ -124,11 +87,11 @@ func (m Model) filterLine(t theme) string { return prompt + m.query } -// listView renders the grouped list body: a header line per Kind, then its -// rows, with the cursor's row carrying the caret and accent name. The body -// is windowed to exactly Layout.ListHeight lines, scrolled so the cursor's -// line (counting Kind header lines) stays on screen. -func (m Model) listView(t theme, groups []kindGroup, rows []match, lay Layout, now time.Time) string { +// groupedBody renders the grouped list body: a header line per Kind, then +// its rows, with the cursor's row carrying the caret and accent name. The +// body is windowed to exactly Layout.ListHeight lines, scrolled so the +// cursor's line (counting Kind header lines) stays on screen. +func (m Model) groupedBody(t theme, groups []kindGroup, rows []match, lay Layout, now time.Time) string { var lines []string i := 0 cursorLine := 0 @@ -147,18 +110,7 @@ func (m Model) listView(t theme, groups []kindGroup, rows []match, lay Layout, n lines = append(lines, t.muted_().Render("no projects match")) } - listH := max(lay.ListHeight, 1) - start := 0 - if cursorLine >= listH { - start = cursorLine - listH + 1 - } - windowed := make([]string, listH) - for i := range windowed { - if idx := start + i; idx < len(lines) { - windowed[i] = lines[idx] - } - } - return strings.Join(windowed, "\n") + return window(lines, cursorLine, lay.ListHeight) } // rowView renders one Project row: NAME STATUS LAST VISIT, with a caret @@ -176,35 +128,20 @@ func (m Model) rowView(t theme, mt match, selected bool, lay Layout, now time.Ti name = truncateName(name, lay.NameWidth) } offset := len([]rune(mt.row.Project.Path)) - len([]rune(mt.row.Project.Name)) - name = padRight(highlightMatches(name, mt.matches, offset, nameStyle, t), lay.NameWidth) + name = padRightOn(plainStyle, highlightMatches(name, mt.matches, offset, nameStyle, t), lay.NameWidth) st, loaded := m.statuses[mt.row.Project.Path] - status := padRight(t.statusCluster(st, loaded), lay.StatusWidth) + status := padRightOn(plainStyle, t.statusCluster(st, loaded), lay.StatusWidth) rel := RelativeTime(mt.row.LastVisit, now) if lay.ShortTime { rel = RelativeTimeShort(mt.row.LastVisit, now) } - rel = padLeft(t.muted_().Render(rel), lay.TimeWidth) + rel = padLeftOn(plainStyle, t.muted_().Render(rel), lay.TimeWidth) return caret + name + " " + status + " " + rel } -// padRight/padLeft pad plain or styled strings to a display width. -func padRight(s string, w int) string { - if d := w - lipgloss.Width(s); d > 0 { - return s + strings.Repeat(" ", d) - } - return s -} - -func padLeft(s string, w int) string { - if d := w - lipgloss.Width(s); d > 0 { - return strings.Repeat(" ", d) + s - } - return s -} - // previewView renders the right-hand preview box for the selected row. func (m Model) previewView(t theme, rows []match, lay Layout, now time.Time) string { var body strings.Builder @@ -212,7 +149,7 @@ func (m Model) previewView(t theme, rows []match, lay Layout, now time.Time) str p := rows[m.cursor].row.Project st, loaded := m.statuses[p.Path] - label := func(s string) string { return t.muted_().Render(padRight(s, 11)) } + label := func(s string) string { return t.muted_().Render(padRightOn(plainStyle, s, 11)) } body.WriteString(t.muted_().Render(p.Kind+"/") + t.accentBold().Render(p.Name) + "\n\n") body.WriteString(label("path") + p.Path + "\n") if loaded && st.Kind == git.Found { diff --git a/internal/picker/view_list.go b/internal/picker/view_list.go index 670d7a3..c69ad0c 100644 --- a/internal/picker/view_list.go +++ b/internal/picker/view_list.go @@ -50,18 +50,7 @@ func (m Model) listBody(t theme, rows []match, lay Layout, now time.Time) string lines = append(lines, t.muted_().Render("no projects match")) } - listH := max(lay.ListHeight, 1) - start := 0 - if m.cursor >= listH { - start = m.cursor - listH + 1 - } - windowed := make([]string, listH) - for i := range windowed { - if idx := start + i; idx < len(lines) { - windowed[i] = lines[idx] - } - } - return strings.Join(windowed, "\n") + return window(lines, m.cursor, lay.ListHeight) } // listRowView renders one row as "▌ STATUS kind/NAME LAST VISIT", padded @@ -126,19 +115,3 @@ func (m Model) listNameField(t theme, mt match, lay Layout, base, kindStyle, nam highlightMatches(name, mt.matches, nameOffset, nameStyle, t) return padRightOn(base, field, lay.NameWidth) } - -// padRightOn and padLeftOn pad a styled string to a display width, putting -// the padding through base so a row background covers it too. -func padRightOn(base lipgloss.Style, s string, w int) string { - if d := w - lipgloss.Width(s); d > 0 { - return s + base.Render(strings.Repeat(" ", d)) - } - return s -} - -func padLeftOn(base lipgloss.Style, s string, w int) string { - if d := w - lipgloss.Width(s); d > 0 { - return base.Render(strings.Repeat(" ", d)) + s - } - return s -} From 3761d802fe19c9b1b9b4633e9f4e36f53fec39b8 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:28:31 +0500 Subject: [PATCH 09/11] test(picker): table the shared View tests over both layouts The frame height, the alternate screen and equal-width rows are shared behaviour, so they run as subtests over both layouts instead of the list layout keeping near-copies of each. The filter line's position is the one that differs, and it is now pinned from one table too: the top of the grouped frame, the line below the body in the list layout. Co-Authored-By: Claude Opus 5 (1M context) --- internal/picker/view_list_test.go | 101 +------------------ internal/picker/view_test.go | 157 ++++++++++++++++++++---------- 2 files changed, 108 insertions(+), 150 deletions(-) diff --git a/internal/picker/view_list_test.go b/internal/picker/view_list_test.go index bff1070..a0893c1 100644 --- a/internal/picker/view_list_test.go +++ b/internal/picker/view_list_test.go @@ -1,31 +1,18 @@ package picker_test import ( - "regexp" "strings" "testing" - "time" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/kryft-dev/cdd/internal/picker" ) -// ansi matches the SGR escape sequences lipgloss wraps each styled -// segment in. A row's text is split across several of them, so assertions -// about what a line reads strip them first. -var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -// plain is s with its styling escapes removed, as the terminal shows it. -func plain(s string) string { return ansi.ReplaceAllString(s, "") } - // listModel builds a Model in the flat list layout, sized to width x // height, over rows. func listModel(rows []picker.Row, width, height int) picker.Model { - m := picker.NewModel(rows, noopStatus, picker.Options{Layout: picker.LayoutList}) - next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height}) - return next.(picker.Model) + return sizedModel(rows, picker.LayoutList, width, height) } // selBarGlyph is the bar the list layout draws on the selected row. @@ -34,22 +21,13 @@ const selBarGlyph = "▌" // TestOptions_LayoutDefaultsToGrouped pins the zero Options to the grouped // layout, so a caller that says nothing keeps the accepted look. func TestOptions_LayoutDefaultsToGrouped(t *testing.T) { - m := listModelWithLayout(t, "") + m := sizedModel(rowsForKindOrder(), "", 120, 24) lines := strings.Split(plain(m.View().Content), "\n") if !strings.Contains(lines[0], "type to filter") { t.Errorf("first line = %q, want the grouped layout's filter line on top", lines[0]) } } -// listModelWithLayout builds a sized Model over rowsForKindOrder under the -// named layout. -func listModelWithLayout(t *testing.T, layout picker.LayoutStyle) picker.Model { - t.Helper() - m := picker.NewModel(rowsForKindOrder(), noopStatus, picker.Options{Layout: layout}) - next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24}) - return next.(picker.Model) -} - // TestModel_ListLayout_FlatHistoryOrder verifies that the list layout walks // rows in the order they were given (History order, never-visited last) // rather than regrouping them under their Kinds. @@ -85,31 +63,6 @@ func TestModel_ListLayout_StartsWithARowNotAHeader(t *testing.T) { } } -// TestModel_ListLayout_PromptBelowList pins the filter prompt to the line -// directly below the list body, where fzf users expect it, with the footer -// underneath. -func TestModel_ListLayout_PromptBelowList(t *testing.T) { - rows := rowsForKindOrder() - m := listModel(rows, 120, 24) - lay := picker.ComputeLayout(20, 1, make([]time.Time, len(rows)), time.Now(), 120, 24) - - lines := strings.Split(plain(m.View().Content), "\n") - prompt := lines[lay.ListHeight] - if !strings.Contains(prompt, "type to filter") { - t.Errorf("line %d = %q, want the filter prompt directly below the list", lay.ListHeight, prompt) - } - - sawKeys := false - for _, l := range lines[lay.ListHeight+1:] { - if strings.Contains(l, "move") && strings.Contains(l, "jump") { - sawKeys = true - } - } - if !sawKeys { - t.Errorf("footer keys line is missing below the prompt:\n%s", m.View().Content) - } -} - // TestModel_ListLayout_KindPrefixAndPreview verifies a row shows "kind/" // before the Project name and that the shared preview pane is still drawn. func TestModel_ListLayout_KindPrefixAndPreview(t *testing.T) { @@ -142,31 +95,6 @@ func TestModel_ListLayout_SelectedRowCarriesBar(t *testing.T) { } } -// TestModel_ListLayout_RowsShareEqualWidth verifies every list row renders -// to the same display width, so the selected row's background highlight -// spans the pane instead of stopping at the text. -func TestModel_ListLayout_RowsShareEqualWidth(t *testing.T) { - rows := rowsForKindOrder() - m := listModel(rows, 40, 24) // narrow: no preview pane, names appear once - - var widths []int - for _, l := range strings.Split(m.View().Content, "\n") { - for _, r := range rows { - if strings.Contains(plain(l), r.Project.Kind+"/"+r.Project.Name) { - widths = append(widths, lipgloss.Width(l)) - } - } - } - if len(widths) != len(rows) { - t.Fatalf("found %d row lines, want %d", len(widths), len(rows)) - } - for i := 1; i < len(widths); i++ { - if widths[i] != widths[0] { - t.Errorf("row %d width = %d, want %d (same as row 0, selected or not)", i, widths[i], widths[0]) - } - } -} - // TestModel_ListLayout_LongKindKeepsTheName verifies that a Kind too long // for the name column is the part that gives way: the Project name stays // on screen, and it keeps its own styling rather than being muted along @@ -212,22 +140,6 @@ func styleOf(line, want string) string { return "" } -// TestModel_ListLayout_FrameMatchesTerminalHeight pins the list layout's -// frame to exactly the terminal height, at every size the grouped layout -// is pinned at. -func TestModel_ListLayout_FrameMatchesTerminalHeight(t *testing.T) { - rows := manyRows(11) - for _, width := range []int{110, 45} { - for _, height := range []int{40, 30, 24, 14, 9} { - m := listModel(rows, width, height) - lines := strings.Split(m.View().Content, "\n") - if len(lines) != height { - t.Errorf("View() at %dx%d produced %d lines, want exactly %d", width, height, len(lines), height) - } - } - } -} - // TestModel_ListLayout_ScrollsCursorIntoView verifies a list far taller // than the terminal scrolls so the cursor's row stays drawn. func TestModel_ListLayout_ScrollsCursorIntoView(t *testing.T) { @@ -260,12 +172,3 @@ func TestModel_ListLayout_NoMatches(t *testing.T) { t.Errorf("View() with no matches is missing the empty preview:\n%s", out) } } - -// TestModel_ListLayout_UsesAlternateScreen pins the list layout to the -// alternate screen, like the grouped one. -func TestModel_ListLayout_UsesAlternateScreen(t *testing.T) { - m := listModel(manyRows(3), 100, 30) - if !m.View().AltScreen { - t.Errorf("View().AltScreen = false, want true") - } -} diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go index 9442173..c7710e7 100644 --- a/internal/picker/view_test.go +++ b/internal/picker/view_test.go @@ -1,6 +1,7 @@ package picker_test import ( + "regexp" "strings" "testing" "time" @@ -11,6 +12,27 @@ import ( "github.com/kryft-dev/cdd/internal/picker" ) +// bothLayouts is every layout the Picker can draw, for the behaviour the +// two share: the frame's height, the alternate screen, equal-width rows +// 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. +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}) + return next.(picker.Model) +} + +// ansi matches the SGR escape sequences lipgloss wraps each styled +// segment in. A row's text is split across several of them, so assertions +// about what a line reads strip them first. +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// plain is s with its styling escapes removed, as the terminal shows it. +func plain(s string) string { return ansi.ReplaceAllString(s, "") } + // manyRows builds n rows all under the same Kind, so the list body is long // enough to need scrolling at a modest terminal height. func manyRows(n int) []picker.Row { @@ -33,9 +55,7 @@ func TestModel_View_PreviewUnderFilterLine(t *testing.T) { rows := []picker.Row{ {Project: picker.Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}, } - m := picker.NewModel(rows, noopStatus, picker.Options{}) - next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - m = next.(picker.Model) + m := sizedModel(rows, picker.LayoutGrouped, 120, 40) out := m.View().Content lines := strings.Split(out, "\n") @@ -57,9 +77,7 @@ func TestModel_View_PreviewUnderFilterLine(t *testing.T) { // on screen. func TestModel_View_ListWindowedToHeight(t *testing.T) { rows := manyRows(40) - m := picker.NewModel(rows, noopStatus, picker.Options{}) - next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 20}) - m = next.(picker.Model) + m := sizedModel(rows, picker.LayoutGrouped, 120, 20) lay := picker.ComputeLayout(10, 1, make([]time.Time, len(rows)), time.Now(), 120, 20) @@ -86,7 +104,7 @@ func TestModel_View_ListWindowedToHeight(t *testing.T) { // Move the cursor to the last row and confirm its name still appears // in the rendered output (i.e. it scrolled into the window). for i := 0; i < len(rows)-1; i++ { - next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = next.(picker.Model) } out = m.View().Content @@ -97,70 +115,107 @@ func TestModel_View_ListWindowedToHeight(t *testing.T) { } // TestModel_View_RowsShareEqualWidth verifies that every list row renders -// to the same display width, selected or not: a fixed-width caret gutter -// on every row, not a caret that shrinks the selected row by a column. +// to the same display width, selected or not: the grouped layout keeps a +// fixed-width caret gutter on every row rather than a caret that shrinks +// the selected row by a column, and the list layout pads every row out so +// the selected row's background spans the pane. func TestModel_View_RowsShareEqualWidth(t *testing.T) { rows := []picker.Row{ {Project: picker.Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}, {Project: picker.Project{Kind: "work", Name: "beta", Path: "/root/work/beta"}}, {Project: picker.Project{Kind: "work", Name: "gamma", Path: "/root/work/gamma"}}, } - m := picker.NewModel(rows, noopStatus, picker.Options{}) - // A narrow terminal keeps the preview pane from being drawn, so each - // Project name appears exactly once, in its list row. - next, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 40}) - m = next.(picker.Model) - - names := []string{"alpha", "beta", "gamma"} - var widths []int - out := m.View().Content - for _, l := range strings.Split(out, "\n") { - for _, n := range names { - if strings.Contains(l, n) { - widths = append(widths, lipgloss.Width(l)) + // The list layout prefixes "kind/"; the grouped layout puts the Kind + // in a header instead, so each looks for its own row text. + label := map[picker.LayoutStyle]func(picker.Row) string{ + picker.LayoutGrouped: func(r picker.Row) string { return r.Project.Name }, + picker.LayoutList: func(r picker.Row) string { return r.Project.Kind + "/" + r.Project.Name }, + } + + for _, layout := range bothLayouts { + t.Run(string(layout), func(t *testing.T) { + // A narrow terminal keeps the preview pane from being drawn, + // so each Project name appears exactly once, in its list row. + m := sizedModel(rows, layout, 30, 40) + + var widths []int + for _, l := range strings.Split(m.View().Content, "\n") { + for _, r := range rows { + if strings.Contains(plain(l), label[layout](r)) { + widths = append(widths, lipgloss.Width(l)) + } + } } - } - } - if len(widths) != len(names) { - t.Fatalf("found %d row lines, want %d", len(widths), len(names)) - } - for i := 1; i < len(widths); i++ { - if widths[i] != widths[0] { - t.Errorf("row %d width = %d, want %d (same as row 0, selected or not)", i, widths[i], widths[0]) - } + if len(widths) != len(rows) { + t.Fatalf("found %d row lines, want %d", len(widths), len(rows)) + } + for i := 1; i < len(widths); i++ { + if widths[i] != widths[0] { + t.Errorf("row %d width = %d, want %d (same as row 0, selected or not)", i, widths[i], widths[0]) + } + } + }) } } // TestModel_View_FrameMatchesTerminalHeight pins the frame to exactly the -// terminal height, with and without the preview pane. One line taller and -// Bubble Tea's inline renderer drops the filter line off the top. +// terminal height in both layouts, with and without the preview pane. One +// line taller and Bubble Tea's renderer drops a line off the top. func TestModel_View_FrameMatchesTerminalHeight(t *testing.T) { rows := manyRows(11) - for _, width := range []int{110, 45} { - for _, height := range []int{40, 30, 24, 14, 9} { - m := picker.NewModel(rows, noopStatus, picker.Options{}) - next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height}) - m = next.(picker.Model) - - lines := strings.Split(m.View().Content, "\n") - if len(lines) != height { - t.Errorf("View() at %dx%d produced %d lines, want exactly %d", width, height, len(lines), height) + for _, layout := range bothLayouts { + t.Run(string(layout), func(t *testing.T) { + for _, width := range []int{110, 45} { + for _, height := range []int{40, 30, 24, 14, 9} { + m := sizedModel(rows, layout, width, height) + lines := strings.Split(m.View().Content, "\n") + if len(lines) != height { + t.Errorf("View() at %dx%d produced %d lines, want exactly %d", width, height, len(lines), height) + } + } } - if !strings.Contains(lines[0], "type to filter") { - t.Errorf("View() at %dx%d: first line %q is not the filter line", width, height, lines[0]) + }) + } +} + +// TestModel_View_FilterLineSitsWhereTheLayoutPutsIt pins the filter line +// to the top of the grouped frame and to the line directly below the list +// body in the list layout, where fzf users expect the prompt. +func TestModel_View_FilterLineSitsWhereTheLayoutPutsIt(t *testing.T) { + rows := manyRows(11) + const width, height = 110, 24 + lay := picker.ComputeLayout(20, 1, make([]time.Time, len(rows)), time.Now(), width, height) + + promptLine := map[picker.LayoutStyle]int{ + picker.LayoutGrouped: 0, + picker.LayoutList: lay.ListHeight, + } + for _, layout := range bothLayouts { + t.Run(string(layout), func(t *testing.T) { + m := sizedModel(rows, layout, width, height) + lines := strings.Split(plain(m.View().Content), "\n") + want := promptLine[layout] + if !strings.Contains(lines[want], "type to filter") { + t.Errorf("line %d = %q, want the filter prompt there", want, lines[want]) } - } + for i, l := range lines { + if i != want && strings.Contains(l, "type to filter") { + t.Errorf("line %d = %q also holds the filter prompt, want it only on line %d", i, l, want) + } + } + }) } } -// TestModel_View_UsesAlternateScreen pins the Picker to the alternate +// TestModel_View_UsesAlternateScreen pins both layouts to the alternate // screen so nothing is left above the shell prompt after a Jump or cancel. func TestModel_View_UsesAlternateScreen(t *testing.T) { - m := picker.NewModel(manyRows(3), noopStatus, picker.Options{}) - next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) - m = next.(picker.Model) - if !m.View().AltScreen { - t.Errorf("View().AltScreen = false, want true") + for _, layout := range bothLayouts { + t.Run(string(layout), func(t *testing.T) { + if m := sizedModel(manyRows(3), layout, 100, 30); !m.View().AltScreen { + t.Errorf("View().AltScreen = false, want true") + } + }) } empty := picker.NewModel(nil, noopStatus, picker.Options{}) if !empty.View().AltScreen { From ef9b527ca04001ae4765733374d7b9b840f408cf Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:28:31 +0500 Subject: [PATCH 10/11] refactor(config): inline the single-caller layout list formatter quoteList existed for one call site with one fixed slice. The error now reads "picker.layout must be grouped or list". Co-Authored-By: Claude Opus 5 (1M context) --- internal/config/config.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 00817c4..cf6e730 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "slices" - "strconv" "strings" toml "github.com/pelletier/go-toml/v2" @@ -194,22 +193,12 @@ func (c *Config) validate(path string) error { } if !slices.Contains(pickerLayouts, c.Picker.Layout) { - return fmt.Errorf("config: %s: picker.layout must be one of %s, got %q", path, quoteList(pickerLayouts), c.Picker.Layout) + return fmt.Errorf("config: %s: picker.layout must be %s, got %q", path, strings.Join(pickerLayouts, " or "), c.Picker.Layout) } return nil } -// quoteList renders values as a quoted, comma-separated list, for the -// error naming the layouts picker.layout accepts. -func quoteList(values []string) string { - quoted := make([]string, len(values)) - for i, v := range values { - quoted[i] = strconv.Quote(v) - } - return strings.Join(quoted, ", ") -} - // expandHome expands a leading "~" in path to the user's home directory. // "$VAR" references are left as-is. func expandHome(path string) (string, error) { From 6d64d2a1eea5b0464c8659eaacb8cb6d52e86ad9 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Sun, 20 Sep 2026 03:30:19 +0500 Subject: [PATCH 11/11] docs(context): add Layout to the glossary The Picker now draws one of two arrangements, and the concept had no agreed name: "layout", "variant" and "style" were all in play across the issue, the prototype and the code. Layout is the term, with the Grouped Layout and the List Layout as its two values, and the README uses them. Naming the values Grouped Layout and List Layout keeps the Picker's own _Avoid_ list intact: a bare "list" still must not mean the Picker. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 4 ++++ README.md | 17 +++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2b8a2eb..6893ba0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,6 +40,10 @@ _Avoid_: Import, index, crawl, rebuild The interactive screen that lists Projects and lets the user choose one to Jump to. _Avoid_: Menu, list, finder, selector +**Layout**: +One of the arrangements in which the Picker draws Projects: the Grouped Layout puts them under Kind headers, the List Layout in one flat run. +_Avoid_: View, mode, style, theme, skin + **Wrapper**: The shell function installed into the user's shell that turns a Project chosen in the Picker into a Jump. _Avoid_: Hook, integration, plugin, shim, alias diff --git a/README.md b/README.md index 115e51b..7378627 100644 --- a/README.md +++ b/README.md @@ -96,15 +96,16 @@ Vim key map (`keys.vim = true`): the list is focused on open. ### Layout -The Picker ships two layouts, selected with `picker.layout`. +The Picker ships two Layouts, selected with `picker.layout`. -`grouped` (the default) groups rows under Kind headers, marks the selected -row with a caret, and puts the filter line above the list. +The **Grouped Layout** (`grouped`, the default) groups Projects under Kind +headers, marks the selected row with a caret, and puts the filter line +above the list. -`list` is a flat fzf-style list: no Kind headers, rows in History order -with never-visited Projects last, `kind/` muted before each Project name, -the filter prompt below the list, and a `▌` bar plus a background -highlight on the selected row. +The **List Layout** (`list`) is a flat fzf-style run: no Kind headers, +Projects in History order with never-visited ones last, `kind/` muted +before each Project name, the filter prompt below the list, and a `▌` bar +plus a background highlight on the selected row. Both draw the same preview pane, use the same keys, status glyphs and colours, and degrade the same way on a narrow terminal. @@ -138,7 +139,7 @@ layout = "grouped" # or "list" for the flat fzf-style layout - `[keys].vim`: when `true`, the Picker opens with the list focused and uses the vim key map described above. Defaults to `false`, the default key map. -- `[picker].layout`: which Picker layout to draw, `"grouped"` (the +- `[picker].layout`: which Layout the Picker draws, `"grouped"` (the default) or `"list"`, as described above. Any other value is a config error.