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 78908c9..7378627 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,22 @@ 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`. + +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. + +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. + ## Config reference `cdd` reads `config.toml` from `$XDG_CONFIG_HOME/cdd/config.toml`, falling @@ -107,6 +123,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 +139,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 Layout the Picker draws, `"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. diff --git a/internal/config/config.go b/internal/config/config.go index daba832..cf6e730 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,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" toml "github.com/pelletier/go-toml/v2" @@ -33,6 +34,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 +54,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 +76,7 @@ func defaultConfig() Config { IncludeHidden: false, History: History{MaxVisits: 1000}, Keys: Keys{Vim: false}, + Picker: Picker{Layout: "grouped"}, } } @@ -70,6 +89,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 +164,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,6 +192,10 @@ 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 %s, got %q", path, strings.Join(pickerLayouts, " or "), c.Picker.Layout) + } + return nil } 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) } 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) 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/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/styles.go b/internal/picker/styles.go index b27a206..b65e795 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 @@ -44,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 ( @@ -60,24 +87,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(plainStyle, 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 +123,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 +226,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 +247,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)) } 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 diff --git a/internal/picker/view.go b/internal/picker/view.go index 296db8b..87db00c 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,39 +24,26 @@ 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)) 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 @@ -69,7 +57,7 @@ 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() } // fullScreen wraps content in a View drawn on the alternate screen. The @@ -99,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 @@ -122,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 @@ -151,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 @@ -187,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 new file mode 100644 index 0000000..c69ad0c --- /dev/null +++ b/internal/picker/view_list.go @@ -0,0 +1,117 @@ +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.visibleRows() + 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")) + } + + return window(lines, m.cursor, lay.ListHeight) +} + +// 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(" ") +} + +// 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. 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 + "/" + 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)) + + kindWidth := len([]rune(kind)) + if over := kindWidth - (lay.NameWidth - listNameFloor); over > 0 { + kindWidth = max(kindWidth-over, 0) + kind = truncateName(kind, kindWidth) + } + name = truncateName(name, max(lay.NameWidth-kindWidth, 0)) + + field := highlightMatches(kind, mt.matches, kindOffset, kindStyle, t) + + highlightMatches(name, mt.matches, nameOffset, nameStyle, t) + return padRightOn(base, field, lay.NameWidth) +} diff --git a/internal/picker/view_list_test.go b/internal/picker/view_list_test.go new file mode 100644 index 0000000..a0893c1 --- /dev/null +++ b/internal/picker/view_list_test.go @@ -0,0 +1,174 @@ +package picker_test + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/kryft-dev/cdd/internal/picker" +) + +// 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 { + return sizedModel(rows, picker.LayoutList, width, height) +} + +// 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 := 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]) + } +} + +// 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_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_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_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) + } +} 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 {