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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 116 additions & 14 deletions fuzzylist.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"github.com/sahilm/fuzzy"
)

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -77,6 +144,7 @@ func (l *fuzzyList) filter() {
l.filtered = append(l.filtered, scoredItem{item: it})
}
l.clampCursor()
l.ensureVisible()
return
}

Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -190,7 +269,7 @@ func (l *fuzzyList) rowIndexAt(y int) int {
}
continue
}
if line == y {
if line == body {
return i
}
line++
Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down
70 changes: 70 additions & 0 deletions fuzzylist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ package main
import (
"strings"
"testing"

"github.com/charmbracelet/lipgloss"
)

// TestFuzzyListSkipsSeparators confirms the cursor starts on a selectable row
Expand Down Expand Up @@ -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)
}
}
}
20 changes: 20 additions & 0 deletions projectsmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions quickactionspicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down