diff --git a/internal/cli/inventory/inventory.go b/internal/cli/inventory/inventory.go index 1131f83cf..79e2dd885 100644 --- a/internal/cli/inventory/inventory.go +++ b/internal/cli/inventory/inventory.go @@ -47,7 +47,13 @@ var ( Now: time.Now, Version: formae.Version, }) - finalModel, err := tui.Run(model, tui.DefaultRunOptions()) + runOpts := tui.DefaultRunOptions() + // Mouse tracking is what makes wheel scrolling behave (see RunOptions.Mouse), + // but it also means the terminal no longer owns click-drag selection, so + // copying an ARN out of the list needs shift held. FORMAE_TUI_NO_MOUSE is the + // escape hatch for terminals where that is worse than the wheel is better. + runOpts.Mouse = os.Getenv("FORMAE_TUI_NO_MOUSE") == "" + finalModel, err := tui.Run(model, runOpts) if err != nil { return err } diff --git a/internal/cli/tui/components/table.go b/internal/cli/tui/components/table.go index a69402c9b..94713e723 100644 --- a/internal/cli/tui/components/table.go +++ b/internal/cli/tui/components/table.go @@ -41,6 +41,7 @@ type Table struct { cols []Column rows [][]string // master data (full column set), in current sort order width int + height int // last height passed to SetSize (bubbles reports a header-adjusted one) sortCol int sortDir SortDirection } @@ -101,11 +102,28 @@ func (t Table) SetRows(rows [][]string) Table { } // SetSize resizes the table and recomputes which columns fit. +// +// A same-size call is a no-op. That is not just a saved reprojection: reproject +// rebuilds the wrapped table's rows, which resets its scroll offset while +// keeping the cursor, so the selection ends up below the visible window. The +// render path calls SetSize on every frame, so without this guard the selected +// row disappears as soon as the cursor travels past one screen. func (t Table) SetSize(width, height int) Table { - t.width = width + if t.width == width && t.height == height { + return t + } + widthChanged := t.width != width + t.width, t.height = width, height t.inner.SetWidth(width) t.inner.SetHeight(height) - return t.reproject() + // Only a width change can change which columns fit, and reproject is not + // free of side effects: it rebuilds the wrapped table's rows, which resets + // its scroll offset while keeping the cursor — leaving the selection below + // the visible window. So reproject on width changes only. + if widthChanged { + t = t.reproject() + } + return t } // SortBy sorts rows by the given column and marks the header with a ▲/▼ @@ -138,6 +156,20 @@ func (t Table) Update(msg tea.Msg) (Table, tea.Cmd) { return t, cmd } +// MoveCursor moves the selection n rows (negative = up) and re-renders the +// table's viewport ONCE. Stepping with n Update calls instead costs n viewport +// re-renders, which is the difference between a smooth wheel notch and a +// backlog. +func (t Table) MoveCursor(n int) Table { + switch { + case n > 0: + t.inner.MoveDown(n) + case n < 0: + t.inner.MoveUp(-n) + } + return t +} + // View renders the table. func (t Table) View() string { return t.inner.View() } diff --git a/internal/cli/tui/inventoryview/model.go b/internal/cli/tui/inventoryview/model.go index 51a1b4083..c4b174229 100644 --- a/internal/cli/tui/inventoryview/model.go +++ b/internal/cli/tui/inventoryview/model.go @@ -7,6 +7,7 @@ package inventoryview import ( "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -61,6 +62,20 @@ type Model struct { detailReqSeq uint64 // helpOpen tracks whether the help overlay is currently displayed. helpOpen bool + // wheelPending is scroll travel (in rows, signed) accumulated from wheel + // events that has not been applied yet; wheelArmed says a settle tick is + // already on its way. See handleMouse. + wheelPending int + wheelArmed bool + // frame caches the last rendered frame so View can hand back the previous + // paint while a wheel burst is still draining. Pointer, so the cache is + // shared by the model values bubbletea copies around. + frame *frameCache +} + +// frameCache holds the last frame View produced. See View / handleMouse. +type frameCache struct { + s string } // New constructs a Model with the four tab specs and sane defaults. @@ -92,6 +107,7 @@ func New(th *theme.Theme, client Client, opts Options) Model { spinner: components.NewSpinner(th), nagSeen: make(map[string]struct{}), query: components.NewQueryBar(th, opts.Query), + frame: &frameCache{}, } if th.Name == "omarchy" { if w, err := theme.NewOmarchyWatcher(); err == nil { @@ -165,6 +181,15 @@ func (m Model) Init() tea.Cmd { // Update handles all incoming messages and key events. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Any message that is not part of a wheel burst ends the burst: apply the + // accumulated travel now so the message is handled against the position the + // user actually scrolled to. + switch msg.(type) { + case tea.MouseMsg, wheelSettleMsg: + default: + m = m.flushWheel() + } + switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -209,6 +234,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) + case wheelSettleMsg: + m.wheelArmed = false + return m.flushWheel(), nil + + case tea.MouseMsg: + return m.handleMouse(msg) + case tea.KeyMsg: return m.handleKey(msg) } @@ -248,6 +280,87 @@ func (m Model) handleTabLoaded(msg tabLoadedMsg) (tea.Model, tea.Cmd) { return m, nil } +// wheelStep is how many rows one mouse-wheel notch moves. Mouse tracking +// delivers exactly one event per notch, so the step is ours to pick: 1 row, +// so a single notch nudges the list by a single row. (A terminal left to +// translate notches into arrow keys itself sends ~3 per notch, which is both +// coarser and three times the work.) +const wheelStep = 1 + +// handleMouse routes wheel events. Every other mouse event is ignored — mouse +// tracking is enabled for scrolling only, not for clicking. +func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if msg.Action != tea.MouseActionPress { + return m, nil + } + + var up bool + switch msg.Button { + case tea.MouseButtonWheelUp: + up = true + case tea.MouseButtonWheelDown: + up = false + default: + return m, nil + } + + // The help overlay is modal and does not scroll. + if m.helpOpen { + return m, nil + } + + // Accumulate the travel instead of applying it here, and let the settle tick + // apply the total. Bubbletea renders after every message, so applying (and + // therefore repainting) per notch means a fast flick queues more work than + // the renderer can retire — the list then keeps moving after the wheel + // stopped, draining the backlog. Accumulating makes each queued notch cost an + // integer add (View reuses the cached frame while wheelPending is non-zero), + // so the queue empties at once and the travel lands in one move. + if up { + m.wheelPending -= wheelStep + } else { + m.wheelPending += wheelStep + } + + if m.wheelArmed { + return m, nil + } + m.wheelArmed = true + return m, tea.Tick(wheelSettle, func(time.Time) tea.Msg { return wheelSettleMsg{} }) +} + +// wheelSettleMsg marks the end of a wheel burst — see handleMouse. +type wheelSettleMsg struct{} + +// wheelSettle is how long accumulated wheel travel waits before it is applied. +// Short enough that a single notch still feels immediate, long enough that a +// flick's worth of notches collapses into one move and one repaint. +const wheelSettle = 8 * time.Millisecond + +// flushWheel applies accumulated wheel travel to whichever surface is scrolling +// (the detail viewport when open, otherwise the active tab's table). It is a +// no-op when nothing is pending, so it is safe to call on every message. +func (m Model) flushWheel() Model { + n := m.wheelPending + if n == 0 { + return m + } + m.wheelPending = 0 + + if m.detailOpen { + if n < 0 { + m.detailViewport.ScrollUp(-n) + } else { + m.detailViewport.ScrollDown(n) + } + return m + } + + t := m.active + m.tabs[t].table = m.tabs[t].table.MoveCursor(n) + return m +} + // handleKey routes keyboard input. func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // ctrl+c always quits, even over the help overlay. @@ -601,12 +714,19 @@ func (m Model) refreshActive() (tea.Model, tea.Cmd) { return m, cmd } -// delegateNav passes navigation keys to the active tab's table and re-syncs. +// delegateNav passes navigation keys to the active tab's table. +// +// It deliberately does NOT re-sync: navigation only moves the table cursor, and +// sync's inputs (allRows, query, sortCol/sortDir, effectiveCols) are all +// unchanged by a cursor move — so a sync here would redo the whole +// filter→sort→style pipeline for an identical result. That cost is paid per +// key event, and terminals expand one mouse-wheel notch into several arrow +// keys, so the queue outran the pipeline and the list kept moving after the +// user stopped scrolling. func (m Model) delegateNav(msg tea.KeyMsg) (tea.Model, tea.Cmd) { t := m.active newTable, cmd := m.tabs[t].table.Update(msg) m.tabs[t].table = newTable - m.tabs[t] = m.tabs[t].sync(m.opts.MaxRows) return m, cmd } @@ -695,6 +815,14 @@ func (m Model) viewDetail() string { return strings.Join(lines, "\n") } +// cache stores the frame View just produced (see View) and returns it unchanged. +func (m Model) cache(frame string) string { + if m.frame != nil { + m.frame.s = frame + } + return frame +} + // narrowFooterThreshold is the terminal width below which the footer hint line // and status bar switch to abbreviated forms. Matches the statuswatch // convention (no analogous constant exists in statuswatch — it uses full hints @@ -708,13 +836,21 @@ func (m Model) View() string { return "" } + // Mid-burst: hand back the last paint. Bubbletea calls View once per message, + // so rendering every queued wheel notch is what let the input queue outrun + // the renderer. The pending travel is applied — and a fresh frame produced — + // by the settle tick a few milliseconds later. + if m.wheelPending != 0 && m.frame != nil && m.frame.s != "" { + return m.frame.s + } + // Help overlay: render over whatever the current screen is. if m.helpOpen { - return m.viewHelp() + return m.cache(m.viewHelp()) } if m.detailOpen { - return m.viewDetail() + return m.cache(m.viewDetail()) } header := components.HeaderBarBranded(m.th, "inventory", "", m.width) @@ -767,7 +903,7 @@ func (m Model) View() string { if len(lines) > m.height { lines = lines[:m.height] } - return strings.Join(lines, "\n") + return m.cache(strings.Join(lines, "\n")) } // viewHelp renders the help overlay centered on the screen, over the header diff --git a/internal/cli/tui/inventoryview/nav_perf_test.go b/internal/cli/tui/inventoryview/nav_perf_test.go new file mode 100644 index 000000000..2ef62aa54 --- /dev/null +++ b/internal/cli/tui/inventoryview/nav_perf_test.go @@ -0,0 +1,199 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package inventoryview + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/platform-engineering-labs/formae/internal/cli/tui/theme" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// benchClient seeds n bucket resources plus one unmanaged row (so per-cell +// styling is exercised). +func benchClient(n int) *fakeClient { + res := make([]pkgmodel.Resource, 0, n+1) + for i := range n { + res = append(res, pkgmodel.Resource{ + NativeID: fmt.Sprintf("arn:aws:s3:::bucket-%04d", i), + Stack: "production", + Type: "AWS::S3::Bucket", + Label: fmt.Sprintf("bucket-%04d", i), + }) + } + res = append(res, pkgmodel.Resource{ + NativeID: "arn:aws:s3:::old-logs", + Stack: "$unmanaged", + Type: "AWS::S3::Bucket", + Label: "aaa-old-logs", // sorts first, so it stays on screen + }) + return &fakeClient{forma: &pkgmodel.Forma{Resources: res}} +} + +func navModel(tb testing.TB, n int) tea.Model { + tb.Helper() + m := New(theme.New("formae"), benchClient(n), Options{ + MaxRows: 200, + Now: func() time.Time { return time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) }, + }) + mm, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + mm, _ = runInit(mm) + return mm +} + +// TestNav_MovesCursorAndKeepsStyling guards delegateNav's dropped sync(): a +// cursor move must still move the cursor and must not lose the per-cell styling +// (the "⚠ unmanaged" stack cell) that sync recorded for the current row set. +func TestNav_MovesCursorAndKeepsStyling(t *testing.T) { + mm := navModel(t, 50) + before := mm.(Model) + require.Equal(t, 0, before.tabs[before.active].table.Cursor()) + require.Contains(t, mm.View(), "⚠ unmanaged") + + mm, _ = mm.Update(tea.KeyMsg{Type: tea.KeyDown}) + after := mm.(Model) + + assert.Equal(t, 1, after.tabs[after.active].table.Cursor()) + // Rows are untouched by navigation, styling included. + assert.Contains(t, mm.View(), "⚠ unmanaged") + assert.Equal(t, + len(before.tabs[before.active].styledCells), + len(after.tabs[after.active].styledCells)) + assert.Equal(t, strings.Count(before.View(), "bucket-"), strings.Count(mm.View(), "bucket-")) +} + +// BenchmarkScrollKey measures one arrow-down keypress (Update + View) — the work +// a single mouse-wheel step costs. Terminals expand one wheel notch into +// several arrow keys, so this must stay well under a frame (~16ms) or the input +// queue outruns the renderer and the list keeps scrolling after the user stops. +func BenchmarkScrollKey(b *testing.B) { + for _, n := range []int{200, 1000} { + b.Run(fmt.Sprintf("rows=%d", n), func(b *testing.B) { + mm := navModel(b, n) + key := tea.KeyMsg{Type: tea.KeyDown} + b.ResetTimer() + for range b.N { + mm, _ = mm.Update(key) + _ = mm.View() + } + }) + } +} + +// TestWheel_Coalesces pins the wheel contract: notches accumulate travel, the +// settle tick applies the total in one move, and non-wheel messages flush the +// pending travel first so they act on the scrolled-to position. +func TestWheel_Coalesces(t *testing.T) { + mm := navModel(t, 50) + down := tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown} + up := tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp} + + // A burst of 5 notches: nothing moves yet, and only the first arms a tick. + var cmd tea.Cmd + mm, cmd = mm.Update(down) + require.NotNil(t, cmd, "first notch arms the settle tick") + for range 4 { + var c tea.Cmd + mm, c = mm.Update(down) + require.Nil(t, c, "further notches ride the armed tick") + } + m := mm.(Model) + require.Equal(t, 0, m.tabs[m.active].table.Cursor(), "travel is deferred") + require.Equal(t, 5*wheelStep, m.wheelPending) + + // The settle tick applies the whole burst at once. + mm, _ = mm.Update(cmd()) + m = mm.(Model) + assert.Equal(t, 5*wheelStep, m.tabs[m.active].table.Cursor()) + assert.Equal(t, 0, m.wheelPending) + + // Upward notches subtract, and any other message flushes them. + mm, _ = mm.Update(up) + mm, _ = mm.Update(up) + mm, _ = mm.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = mm.(Model) + assert.Equal(t, 0, m.wheelPending) + // 5 down, 2 up, then one arrow-key down. + assert.Equal(t, 5*wheelStep-2*wheelStep+1, m.tabs[m.active].table.Cursor()) + + // Release events are not moves. + mm, _ = mm.Update(tea.MouseMsg{Action: tea.MouseActionRelease, Button: tea.MouseButtonWheelDown}) + assert.Equal(t, 0, mm.(Model).wheelPending) +} + +// TestWheel_CachedFrameWhilePending checks the mid-burst frame reuse: View must +// hand back the previous paint while travel is pending, then repaint after the +// settle tick. +func TestWheel_CachedFrameWhilePending(t *testing.T) { + mm := navModel(t, 50) + first := mm.View() + require.NotEmpty(t, first) + + mm, cmd := mm.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown}) + assert.Equal(t, first, mm.View(), "pending burst reuses the cached frame") + + mm, _ = mm.Update(cmd()) + assert.NotEqual(t, first, mm.View(), "settled burst repaints") +} + +// BenchmarkWheelNotch measures one queued wheel notch mid-burst: the per-event +// cost that has to stay far below the interval between notches, or the list +// keeps scrolling after the user stops. +func BenchmarkWheelNotch(b *testing.B) { + mm := navModel(b, 1000) + msg := tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown} + b.ResetTimer() + for range b.N { + mm, _ = mm.Update(msg) + _ = mm.View() + } +} + +// BenchmarkWheelSettle measures the settle tick: one move plus one real repaint, +// paid once per burst rather than once per notch. +func BenchmarkWheelSettle(b *testing.B) { + mm := navModel(b, 1000) + notch := tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown} + b.ResetTimer() + for range b.N { + mm, _ = mm.Update(notch) + mm, _ = mm.Update(wheelSettleMsg{}) + _ = mm.View() + } +} + +// TestScroll_SelectionStaysVisible guards the scroll-follow contract: however +// far the list has travelled, the selected row is in the rendered frame. It used +// to leave the frame after roughly one screen, because the render path resized +// the table every frame and the resize rebuilt the wrapped table's rows, which +// reset its scroll offset while keeping the cursor. +func TestScroll_SelectionStaysVisible(t *testing.T) { + for _, downs := range []int{5, 40, 150} { + mm := navModel(t, 200) + for range downs { + mm, _ = mm.Update(tea.KeyMsg{Type: tea.KeyDown}) + } + m := mm.(Model) + sel := m.tabs[m.active].table.SelectedRow()[0] + require.Equal(t, downs, m.tabs[m.active].table.Cursor()) + assert.Contains(t, mm.View(), sel, "selected row must be on screen after %d downs", downs) + } + + // Same via the wheel, whose travel lands in one move on settle. + mm := navModel(t, 200) + for range 60 { + mm, _ = mm.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown}) + } + mm, _ = mm.Update(wheelSettleMsg{}) + m := mm.(Model) + assert.Contains(t, mm.View(), m.tabs[m.active].table.SelectedRow()[0]) +} diff --git a/internal/cli/tui/inventoryview/tab.go b/internal/cli/tui/inventoryview/tab.go index 3b194a481..45a669180 100644 --- a/internal/cli/tui/inventoryview/tab.go +++ b/internal/cli/tui/inventoryview/tab.go @@ -114,6 +114,23 @@ func (t tabModel) visible(maxRows int) (vis []row, total int) { return vis, total } +// filteredCount returns the post-filter row count — the same number visible() +// reports as total, but without the sort and cap work. The view path only needs +// the count, and it runs on every frame, so it must not pay for a full sort. +func (t tabModel) filteredCount() int { + if t.spec.serverQuery || t.query == "" { + return len(t.allRows) + } + needle := strings.ToLower(t.query) + n := 0 + for _, r := range t.allRows { + if rowMatchesFilter(r, needle) { + n++ + } + } + return n +} + // rowMatchesFilter reports whether any cell in r contains needle (already lower-cased). func rowMatchesFilter(r row, needle string) bool { for _, cell := range r.cells { diff --git a/internal/cli/tui/inventoryview/tab_test.go b/internal/cli/tui/inventoryview/tab_test.go index 20e24394c..d984762ea 100644 --- a/internal/cli/tui/inventoryview/tab_test.go +++ b/internal/cli/tui/inventoryview/tab_test.go @@ -235,3 +235,24 @@ func TestNewTabModel_InitialState(t *testing.T) { assert.Empty(t, tm.allRows) assert.Empty(t, tm.query) } + +// TestFilteredCount_MatchesVisibleTotal pins filteredCount to visible()'s total. +// filteredCount exists so the render path skips visible()'s sort; the two filter +// predicates must not drift, or the "Showing X of Y" count, the truncation +// marker and the rendered rows would disagree. +func TestFilteredCount_MatchesVisibleTotal(t *testing.T) { + specs := newSpecs(nil) + for _, spec := range specs { + tab := newTabModel(theme.New("formae"), spec) + tab.allRows = []row{ + {cells: []string{"alpha", "production", "AWS::S3::Bucket", "arn:alpha"}}, + {cells: []string{"beta", "staging", "AWS::EC2::Instance", "i-beta"}}, + {cells: []string{"gamma", "production", "AWS::S3::Bucket", "arn:gamma"}}, + } + for _, q := range []string{"", "production", "ALPHA", "s3", "nomatch"} { + tab.query = q + _, total := tab.visible(0) + assert.Equal(t, total, tab.filteredCount(), "tab %q query %q", spec.entity, q) + } + } +} diff --git a/internal/cli/tui/inventoryview/tabview.go b/internal/cli/tui/inventoryview/tabview.go index 0ec87d948..b70a24e6c 100644 --- a/internal/cli/tui/inventoryview/tabview.go +++ b/internal/cli/tui/inventoryview/tabview.go @@ -319,8 +319,20 @@ func applyCellStyles(lines []string, cells [][]styledCell, tbl components.Table, const headerLines = 2 // bubbles header row + separator row dataStart := headerLines - for row, styledRow := range cells { - lineIdx := dataStart + row + + // The rendered lines are a WINDOW over the row set — bubbles/table only + // renders around the cursor — while cells is indexed from row 0. Line up the + // two before replacing anything, or every style lands on the wrong row (and, + // failing its plain-text check, silently disappears) as soon as the table has + // scrolled. + first := 0 + if len(colVisStart) > 0 { + first = windowFirstRow(out, cells, dataStart, colVisStart[0], colVisWidth[0]) + } + + for row := first; row < len(cells); row++ { + styledRow := cells[row] + lineIdx := dataStart + (row - first) if lineIdx >= len(out) { break } @@ -367,6 +379,37 @@ func applyCellStyles(lines []string, cells [][]styledCell, tbl components.Table, return out } +// windowFirstRow reports which row of cells the first rendered data line shows, +// by matching that line's leading column against each row's plain text for that +// column. Returns 0 when there is nothing to match (no data lines, or no match — +// in which case the caller behaves as it did before, aligning from row 0). +func windowFirstRow(lines []string, cells [][]styledCell, dataStart, vStart, vWidth int) int { + if dataStart >= len(lines) || vWidth <= 0 { + return 0 + } + runes := []rune(ansi.Strip(lines[dataStart])) + if vStart >= len(runes) { + return 0 + } + vEnd := min(vStart+vWidth, len(runes)) + head := strings.TrimSpace(string(runes[vStart:vEnd])) + if head == "" { + return 0 + } + for row, styledRow := range cells { + for _, sc := range styledRow { + if sc.col != 0 { + continue + } + if strings.TrimSpace(sc.plain) == head { + return row + } + break + } + } + return 0 +} + // replaceInAnsiLine replaces the runes at visual positions [visStart, visEnd) // in an ANSI-encoded line with the styled string. It walks the line rune by // rune, skipping ANSI escape sequences (which consume no visual columns), to @@ -541,7 +584,7 @@ func runeIndex(haystack, needle []rune) int { // loadedView renders the table and optional truncation marker. func (t tabModel) loadedView(th *theme.Theme, maxRows int) []string { - _, total := t.visible(maxRows) + total := t.filteredCount() shown := total if maxRows > 0 && shown > maxRows { shown = maxRows @@ -620,7 +663,7 @@ func (t tabModel) loadedView(th *theme.Theme, maxRows int) []string { // statusLine returns "Showing of ", with appropriate // suffix for filtered/truncated cases. func (t tabModel) statusLine(maxRows int) string { - _, total := t.visible(maxRows) + total := t.filteredCount() shown := total if maxRows > 0 && shown > maxRows { shown = maxRows @@ -641,7 +684,7 @@ func (t tabModel) statusLine(maxRows int) string { // (width < narrowFooterThreshold). It drops the entity noun and appends compact // key glyphs: "Showing N of M · ↑↓ enter / s q". func (t tabModel) statusLineNarrow(maxRows int) string { - _, total := t.visible(maxRows) + total := t.filteredCount() shown := total if maxRows > 0 && shown > maxRows { shown = maxRows diff --git a/internal/cli/tui/launch.go b/internal/cli/tui/launch.go index 2ab3c9547..ce87bf290 100644 --- a/internal/cli/tui/launch.go +++ b/internal/cli/tui/launch.go @@ -22,6 +22,17 @@ type RunOptions struct { // Output overrides the output writer (default: os.Stdout). // Useful for testing. Output io.Writer + + // Mouse enables mouse tracking (cell motion). Opt-in per TUI: it is only + // worth the cost for models that actually handle tea.MouseMsg, because + // tracking takes click-drag text selection away from the terminal (most + // terminals still select with shift held). + // + // The win is on wheel input: without tracking, a terminal in the alternate + // screen translates one wheel notch into several arrow keys, so a fast flick + // floods the event queue and the list keeps moving after the user stops. With + // tracking, one notch is one event the model can size itself. + Mouse bool } // DefaultRunOptions returns RunOptions suitable for interactive TUI commands. @@ -55,6 +66,10 @@ func buildProgramOptions(opts RunOptions) []tea.ProgramOption { progOpts = append(progOpts, tea.WithAltScreen()) } + if opts.Mouse { + progOpts = append(progOpts, tea.WithMouseCellMotion()) + } + if opts.Output != nil { progOpts = append(progOpts, tea.WithOutput(opts.Output)) }