From 42a8d04dc6a6f48d6bcc51effe5af323c59f7660 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:22:20 +0200 Subject: [PATCH] fix(inventory): make wheel scrolling stop when the user does Scrolling the inventory list with the mouse wheel kept moving well after the wheel stopped, and the further the list travelled the worse it got. Three separate causes, all on the per-event path: - Every navigation key re-ran the whole filter -> sort -> truncate -> style pipeline over the capped row set, and the render path re-ran the filter and sort twice more per frame, for a result that a cursor move cannot change. Navigation no longer re-syncs, and the view path counts filtered rows without sorting them. - Without mouse tracking a terminal in the alternate screen turns one wheel notch into several arrow keys, so a notch cost several full frames. The inventory TUI now enables tracking and sizes the step itself; other TUIs are unchanged, and FORMAE_TUI_NO_MOUSE opts out for anyone who would rather keep click-drag text selection. - Bubbletea renders after every message, so a fast flick still queued more frames than the renderer could retire. Wheel travel now accumulates and a settle tick applies the total in one move and one repaint; mid-burst frames reuse the last paint. Per queued notch: 8.45ms -> 0.011ms, with one 2.2ms settle per burst. While fixing the render path, two display bugs surfaced that scrolling made easy to hit: resizing the table rebuilt the wrapped table's rows and reset its scroll offset while keeping the cursor, so the selected row left the screen after roughly one screenful of travel (the render path resizes on every frame); and per-cell styling was applied by absolute row index to a window of rendered lines, so it landed on the wrong rows once scrolled. Same-size resizes are now a no-op, only width changes reproject, and cell styling aligns to the rendered window. --- internal/cli/inventory/inventory.go | 8 +- internal/cli/tui/components/table.go | 36 +++- internal/cli/tui/inventoryview/model.go | 146 ++++++++++++- .../cli/tui/inventoryview/nav_perf_test.go | 199 ++++++++++++++++++ internal/cli/tui/inventoryview/tab.go | 17 ++ internal/cli/tui/inventoryview/tab_test.go | 21 ++ internal/cli/tui/inventoryview/tabview.go | 53 ++++- internal/cli/tui/launch.go | 15 ++ 8 files changed, 482 insertions(+), 13 deletions(-) create mode 100644 internal/cli/tui/inventoryview/nav_perf_test.go 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)) }