diff --git a/fuzzylist.go b/fuzzylist.go index ae9039c..ec259ea 100644 --- a/fuzzylist.go +++ b/fuzzylist.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" "github.com/sahilm/fuzzy" ) @@ -45,6 +46,14 @@ type fuzzyList struct { filtered []scoredItem cursor int lastQuery string + + // Viewport. maxRows caps how many body lines view() emits, scrolling the + // window (lineOffset) so the cursor's row stays visible; maxWidth + // hard-truncates every rendered line so nothing soft-wraps in the terminal + // and breaks the list's line accounting. Zero means unbounded. + maxRows int + maxWidth int + lineOffset int } // newFuzzyList builds a list over items with a focused, empty query box. @@ -59,6 +68,64 @@ func newFuzzyList(placeholder string, items []listItem) fuzzyList { return l } +// setViewport tells the list how much room it has: rows is the body-line budget +// below the query/prompt block, width the hard cap on any rendered line's +// display columns. Zero or negative disables the corresponding limit. +func (l *fuzzyList) setViewport(rows, width int) { + l.maxRows = rows + l.maxWidth = width + l.ensureVisible() +} + +// lineOf returns the body-line index (0 = the first line after the query/prompt +// block) where filtered[target] renders, using the same accounting as view() +// and rowIndexAt: one line per selectable row, a blank plus an optional heading +// line per separator. A target past the end returns the body's total line +// count. +func (l *fuzzyList) lineOf(target int) int { + line := 0 + for i, s := range l.filtered { + if i == target { + break + } + if !s.item.selectable { + line++ // the separator's leading blank line + if s.item.name != "" { + line++ // its heading line + } + continue + } + line++ + } + return line +} + +// ensureVisible scrolls lineOffset so the cursor's row sits inside the +// viewport, clamping the window to the body's real extent. With no row budget +// the list never scrolls. +func (l *fuzzyList) ensureVisible() { + if l.maxRows <= 0 { + l.lineOffset = 0 + return + } + if max := l.lineOf(len(l.filtered)) - l.maxRows; l.lineOffset > max { + l.lineOffset = max + } + if l.lineOffset < 0 { + l.lineOffset = 0 + } + if len(l.filtered) == 0 { + return + } + cur := l.lineOf(l.cursor) + if cur < l.lineOffset { + l.lineOffset = cur + } + if cur >= l.lineOffset+l.maxRows { + l.lineOffset = cur - l.maxRows + 1 + } +} + // filter recomputes the visible rows from the current query. An empty query // shows every item — separators included — in its natural order. A non-empty // query fuzzy-matches only the selectable items (separators are dropped while @@ -77,6 +144,7 @@ func (l *fuzzyList) filter() { l.filtered = append(l.filtered, scoredItem{item: it}) } l.clampCursor() + l.ensureVisible() return } @@ -103,6 +171,7 @@ func (l *fuzzyList) filter() { } l.clampCursor() + l.ensureVisible() } // clampCursor keeps the cursor in range and parked on a selectable row, moving @@ -142,6 +211,7 @@ func (l *fuzzyList) moveUp() { for i := l.cursor - 1; i >= 0; i-- { if l.filtered[i].item.selectable { l.cursor = i + l.ensureVisible() return } } @@ -151,6 +221,7 @@ func (l *fuzzyList) moveDown() { for i := l.cursor + 1; i < len(l.filtered); i++ { if l.filtered[i].item.selectable { l.cursor = i + l.ensureVisible() return } } @@ -179,9 +250,17 @@ const listPromptLines = 2 // is the prompt, a blank spacer, a separator/heading, or past the end of the // list. It mirrors view()'s exact line accounting — the prompt line, the blank // spacer, then one line per selectable row and a blank (plus an optional heading -// line) per separator — so this and view() must change together. +// line) per separator, offset by the viewport's scroll position — so this and +// view() must change together. func (l *fuzzyList) rowIndexAt(y int) int { - line := listPromptLines + if y < listPromptLines { + return -1 // the query/prompt block, never a row + } + body := y - listPromptLines + l.lineOffset + if l.maxRows > 0 && body >= l.lineOffset+l.maxRows { + return -1 // below the visible window + } + line := 0 for i, s := range l.filtered { if !s.item.selectable { line++ // the separator's leading blank line @@ -190,7 +269,7 @@ func (l *fuzzyList) rowIndexAt(y int) int { } continue } - if line == y { + if line == body { return i } line++ @@ -222,7 +301,9 @@ func (l *fuzzyList) editQuery(msg tea.Msg) tea.Cmd { // view renders the query line, the match count (selectable rows only), and the // result rows. Separators render as a blank line plus an optional dim heading; -// emptyMsg is shown when no selectable row matches the query. +// emptyMsg is shown when no selectable row matches the query. When a viewport +// is set (setViewport) only the maxRows-line window at lineOffset is emitted, +// and every line is truncated to maxWidth so nothing soft-wraps. func (l fuzzyList) view(emptyMsg string) string { var b strings.Builder @@ -244,32 +325,53 @@ func (l fuzzyList) view(emptyMsg string) string { b.WriteString(countStyle.Render(fmt.Sprintf("%d/%d", matched, total))) b.WriteString("\n\n") + // Build the body one screen line at a time so the viewport can slice it. + // The accounting here must stay in step with lineOf / rowIndexAt. + var lines []string if matched == 0 { - b.WriteString(descStyle.Render(" " + emptyMsg)) - b.WriteString("\n") + lines = append(lines, descStyle.Render(" "+emptyMsg)) } for i, s := range l.filtered { it := s.item if !it.selectable { // A blank line separates groups; a heading (if any) labels the group. - b.WriteString("\n") + lines = append(lines, "") if it.name != "" { - b.WriteString(headingStyle.Render(it.name)) - b.WriteString("\n") + lines = append(lines, headingStyle.Render(it.name)) } continue } selected := i == l.cursor + var row strings.Builder if selected { - b.WriteString(barStyle.Render("▌ ")) + row.WriteString(barStyle.Render("▌ ")) } else { - b.WriteString(" ") + row.WriteString(" ") } - b.WriteString(highlightName(it.name, s.matched, selected)) + row.WriteString(highlightName(it.name, s.matched, selected)) if it.desc != "" { - b.WriteString(" ") - b.WriteString(descStyle.Render(it.desc)) + row.WriteString(" ") + row.WriteString(descStyle.Render(it.desc)) + } + lines = append(lines, row.String()) + } + + if l.maxRows > 0 && len(lines) > l.maxRows { + start := l.lineOffset + if max := len(lines) - l.maxRows; start > max { + start = max + } + if start < 0 { + start = 0 + } + lines = lines[start : start+l.maxRows] + } + + for _, ln := range lines { + if l.maxWidth > 0 { + ln = ansi.Truncate(ln, l.maxWidth, "…") } + b.WriteString(ln) b.WriteString("\n") } return b.String() diff --git a/fuzzylist_test.go b/fuzzylist_test.go index d35057d..be48fe5 100644 --- a/fuzzylist_test.go +++ b/fuzzylist_test.go @@ -9,6 +9,8 @@ package main import ( "strings" "testing" + + "github.com/charmbracelet/lipgloss" ) // TestFuzzyListSkipsSeparators confirms the cursor starts on a selectable row @@ -205,3 +207,71 @@ func TestFuzzyListQueryChangeResetsCursor(t *testing.T) { t.Fatalf("selectedIndex after clearing query = %d, want 1 (alpha)", got) } } + +// TestFuzzyListViewportScrollsWithCursor confirms that with a row budget set, +// view() emits at most that many body lines, the window follows the cursor in +// both directions, and rowIndexAt maps clicks through the scrolled window. +func TestFuzzyListViewportScrollsWithCursor(t *testing.T) { + var items []listItem + names := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} + for i, n := range names { + items = append(items, listItem{name: n, selectable: true, ref: i}) + } + l := newFuzzyList("", items) + l.setViewport(3, 80) + + bodyLines := func() []string { + out := strings.Split(strings.TrimRight(l.view("none"), "\n"), "\n") + return out[listPromptLines:] // drop the query line and its blank spacer + } + + if got := len(bodyLines()); got != 3 { + t.Fatalf("visible body lines = %d, want 3", got) + } + + // Walk to the last row: the window must scroll so the cursor stays visible. + for i := 0; i < len(names)-1; i++ { + l.moveDown() + } + if got := l.selectedIndex(); got != 5 { + t.Fatalf("after walking down selected = %d, want 5", got) + } + lines := bodyLines() + if got := len(lines); got != 3 { + t.Fatalf("visible body lines after scroll = %d, want 3", got) + } + if !strings.Contains(lines[len(lines)-1], "foxtrot") { + t.Fatalf("cursor row not visible after scrolling down: %q", lines) + } + + // A click on the first visible body line must resolve through the scroll + // offset to the row actually drawn there. + if got := l.rowIndexAt(listPromptLines); got != 3 { + t.Fatalf("rowIndexAt(top of window) = %d, want 3 (delta)", got) + } + + // Walking back up scrolls the window back to the top. + for i := 0; i < len(names)-1; i++ { + l.moveUp() + } + lines = bodyLines() + if !strings.Contains(lines[0], "alpha") { + t.Fatalf("cursor row not visible after scrolling back up: %q", lines) + } +} + +// TestFuzzyListViewportTruncatesRows confirms the width cap keeps every emitted +// line within the viewport so nothing soft-wraps in the terminal. +func TestFuzzyListViewportTruncatesRows(t *testing.T) { + long := strings.Repeat("x", 60) + l := newFuzzyList("", []listItem{ + {name: long, desc: strings.Repeat("y", 60), selectable: true, ref: 0}, + }) + l.setViewport(5, 20) + + for _, line := range strings.Split(strings.TrimRight(l.view("none"), "\n"), "\n")[listPromptLines:] { + if w := lipgloss.Width(line); w > 20 { + t.Fatalf("body line is %d columns wide, want <= 20: %q", w, line) + } + } +} diff --git a/projectsmodel.go b/projectsmodel.go index 82b7506..9c3e3e4 100644 --- a/projectsmodel.go +++ b/projectsmodel.go @@ -28,6 +28,24 @@ const docsURL = "https://github.com/cloudmanic/herdr-plus" // mouse click's screen row minus this offset is the list-local line for clickRow. const projectsHeaderLines = 2 +// projectsChromeLines is every fixed line around the list's body in +// browserView: the title bar and its blank line (projectsHeaderLines), the +// query/prompt block (listPromptLines), the minimum one-line gap above the +// detail bar, the four-line detail box, and the footer. The list's body budget +// is the pane height minus this, so the browser always fits the pane instead of +// overflowing it when there are more projects than rows. +const projectsChromeLines = projectsHeaderLines + listPromptLines + 1 + 4 + 1 + +// listViewport applies the pane size to the embedded list: the body-line budget +// left after the fixed chrome, and the full pane width as the row cap. +func (m *projectsModel) listViewport() { + budget := m.height - projectsChromeLines + if budget < 1 { + budget = 1 + } + m.list.setViewport(budget, m.width) +} + type projectsMode int const ( @@ -212,6 +230,7 @@ func (m projectsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.listViewport() return m, nil case tea.KeyMsg: @@ -280,6 +299,7 @@ func (m projectsModel) updateBranch(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.listViewport() return m, nil case tea.KeyMsg: diff --git a/quickactionspicker.go b/quickactionspicker.go index d9acf50..b954f17 100644 --- a/quickactionspicker.go +++ b/quickactionspicker.go @@ -126,6 +126,8 @@ func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.actionList.setViewport(m.listBudget(), msg.Width) + m.optionList.setViewport(m.listBudget(), msg.Width) return m, nil case tea.MouseMsg: @@ -215,6 +217,7 @@ func (m pickerModel) activateAction() (tea.Model, tea.Cmd) { case TypeSelect: m.current = &a m.optionList = newFuzzyList("Pick an option…", optionItems(a.Options)) + m.optionList.setViewport(m.listBudget(), m.width) m.stage = stageSelect return m, textinput.Blink case TypeForm: @@ -288,6 +291,18 @@ func (m pickerModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +// listBudget is the body-line budget for a picker list at the current pane +// size: the height minus the title bar and its blank line (pickerHeaderLines), +// the query/prompt block (listPromptLines), and the blank-plus-footer below the +// list — so the picker always fits the pane instead of overflowing it. +func (m pickerModel) listBudget() int { + b := m.height - pickerHeaderLines - listPromptLines - 2 + if b < 1 { + b = 1 + } + return b +} + // forwardToInput passes a non-key message to whichever input is active so the // cursor keeps blinking and text keeps flowing in every stage. func (m pickerModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) {