mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
30f2bc243d
- Match View() and getItemAndLineAtY() row counts for empty items so streaming-reasoning placeholders no longer offset hit-testing by one row each (exposed when extension widgets like subagent-monitor shrink the scrollback). - Honor IsLineInRange's endCol=-1 'to end of line' sentinel in HighlightLine and ExtractText so the start row of a multi-line drag actually renders highlighted and is included in clipboard copies. - Add regression tests for both invariants in scrolllist and selection.
182 lines
6.5 KiB
Go
182 lines
6.5 KiB
Go
package ui
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// fakeItem is a deterministic MessageItem for ScrollList tests.
|
||
type fakeItem struct {
|
||
id string
|
||
lines int
|
||
}
|
||
|
||
func (f *fakeItem) ID() string { return f.id }
|
||
func (f *fakeItem) Render(_ int) string {
|
||
if f.lines <= 0 {
|
||
return ""
|
||
}
|
||
parts := make([]string, f.lines)
|
||
for i := range parts {
|
||
parts[i] = fmt.Sprintf("%s-line-%d", f.id, i)
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
func (f *fakeItem) Height() int { return f.lines }
|
||
|
||
// makeItems builds n fake items of `lines` height each.
|
||
func makeItems(n, lines int) []MessageItem {
|
||
out := make([]MessageItem, n)
|
||
for i := range out {
|
||
out[i] = &fakeItem{id: fmt.Sprintf("item-%d", i), lines: lines}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestScrollList_MouseDownPreventsAutoScroll verifies the core fix for the
|
||
// copy-selection drift bug: while the user has the mouse button held
|
||
// (drag-selecting), incoming content updates must NOT shift the viewport,
|
||
// because doing so moves the highlighted content out from under the cursor.
|
||
func TestScrollList_MouseDownPreventsAutoScroll(t *testing.T) {
|
||
sl := NewScrollList(80, 10)
|
||
sl.SetItems(makeItems(20, 2)) // 40 lines of content into a 10-line viewport
|
||
// Capture the auto-scrolled-to-bottom position.
|
||
startOffsetIdx := sl.offsetIdx
|
||
startOffsetLine := sl.offsetLine
|
||
|
||
// User clicks somewhere in the visible area, starting a drag-select.
|
||
if !sl.HandleMouseDown(5, 3) {
|
||
t.Fatalf("HandleMouseDown should accept a click inside the viewport")
|
||
}
|
||
if !sl.IsMouseDown() {
|
||
t.Fatalf("IsMouseDown should be true after HandleMouseDown")
|
||
}
|
||
|
||
// New content arrives. With autoScroll still true, SetItems would
|
||
// normally call GotoBottom() and shift the viewport. The fix should
|
||
// suppress that while MouseDown is held.
|
||
sl.SetItems(makeItems(30, 2)) // 60 lines now
|
||
if sl.offsetIdx != startOffsetIdx || sl.offsetLine != startOffsetLine {
|
||
t.Errorf("viewport scrolled during active drag: was (%d,%d), now (%d,%d)",
|
||
startOffsetIdx, startOffsetLine, sl.offsetIdx, sl.offsetLine)
|
||
}
|
||
|
||
// User releases the mouse — drag is over.
|
||
sl.HandleMouseUp()
|
||
if sl.IsMouseDown() {
|
||
t.Fatalf("IsMouseDown should be false after HandleMouseUp")
|
||
}
|
||
|
||
// After release, a fresh content update should resume auto-scrolling
|
||
// (move the offset to track the new bottom).
|
||
afterReleaseIdx := sl.offsetIdx
|
||
afterReleaseLine := sl.offsetLine
|
||
sl.SetItems(makeItems(50, 2))
|
||
if sl.offsetIdx == afterReleaseIdx && sl.offsetLine == afterReleaseLine {
|
||
t.Errorf("autoscroll did not resume after MouseUp: offset stuck at (%d,%d)",
|
||
afterReleaseIdx, afterReleaseLine)
|
||
}
|
||
}
|
||
|
||
// TestScrollList_DragDisablesAutoScroll verifies that any successful
|
||
// HandleMouseDrag call clears autoScroll, even when HandleMouseDown didn't
|
||
// observe it (e.g. a stale wheel-down event set it back to true mid-stream).
|
||
func TestScrollList_DragDisablesAutoScroll(t *testing.T) {
|
||
sl := NewScrollList(80, 10)
|
||
sl.SetItems(makeItems(20, 2))
|
||
|
||
// Begin a selection.
|
||
if !sl.HandleMouseDown(5, 3) {
|
||
t.Fatalf("HandleMouseDown failed")
|
||
}
|
||
// Simulate an external code path that re-enabled autoScroll while
|
||
// MouseDown is still held (the precise condition that caused drift).
|
||
sl.autoScroll = true
|
||
|
||
// Drag motion should hard-lock the viewport again.
|
||
if !sl.HandleMouseDrag(10, 4) {
|
||
t.Fatalf("HandleMouseDrag failed")
|
||
}
|
||
if sl.autoScroll {
|
||
t.Errorf("HandleMouseDrag must clear autoScroll to prevent mid-drag jumps")
|
||
}
|
||
}
|
||
|
||
// TestScrollList_SetItemsRespectsMouseDown is the most direct regression
|
||
// test: even with autoScroll enabled and new content appended at the
|
||
// bottom, SetItems must not move the viewport while a mouse drag is in
|
||
// progress. This is what caused the "highlighting shifts by 1+ rows
|
||
// during streaming" symptom reported by the user.
|
||
func TestScrollList_SetItemsRespectsMouseDown(t *testing.T) {
|
||
sl := NewScrollList(80, 5)
|
||
sl.SetItems(makeItems(10, 2)) // 20 lines into a 5-line viewport
|
||
// At bottom.
|
||
preIdx, preLine := sl.offsetIdx, sl.offsetLine
|
||
|
||
// Hold mouse down (no actual drag needed).
|
||
if !sl.HandleMouseDown(0, 0) {
|
||
t.Fatalf("HandleMouseDown failed")
|
||
}
|
||
|
||
// Append several more items as if streaming. With the bug, each
|
||
// SetItems would call GotoBottom and shift the offset.
|
||
for n := 11; n <= 15; n++ {
|
||
sl.SetItems(makeItems(n, 2))
|
||
if sl.offsetIdx != preIdx || sl.offsetLine != preLine {
|
||
t.Fatalf("viewport drifted during streaming with mouse held: "+
|
||
"start=(%d,%d) now=(%d,%d) after adding item %d",
|
||
preIdx, preLine, sl.offsetIdx, sl.offsetLine, n)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestScrollList_EmptyItemsDoNotShiftMouseMapping is the regression test
|
||
// for the second drift bug: items that render to "" must contribute the
|
||
// same number of rows in View() (zero) as in renderedHeight(), or mouse
|
||
// hit-testing drifts by one row per empty item between offsetIdx and the
|
||
// cursor. This was surfaced by extension widgets (e.g. subagent-monitor)
|
||
// that shrink the scrollback so empty streaming-reasoning items end up
|
||
// in the visible window.
|
||
//
|
||
// Setup: 1 normal item + 1 empty item + 1 normal item. Click on the line
|
||
// where the third item begins. With the bug, getItemAndLineAtY skips the
|
||
// empty item (renderedHeight=0) and reports lineIdx pointing one row
|
||
// past where View() actually painted that line.
|
||
func TestScrollList_EmptyItemsDoNotShiftMouseMapping(t *testing.T) {
|
||
sl := NewScrollList(80, 10)
|
||
sl.SetItems([]MessageItem{
|
||
&fakeItem{id: "a", lines: 2}, // viewY 0–1
|
||
&fakeItem{id: "empty", lines: 0}, // renders "" — contributes 0 rows
|
||
&fakeItem{id: "b", lines: 2}, // viewY 2–3
|
||
})
|
||
|
||
// Render the viewport once so the cache reflects what View() actually
|
||
// emits (this is the path that previously diverged from renderedHeight
|
||
// for empty items).
|
||
rendered := sl.View()
|
||
lines := strings.Split(rendered, "\n")
|
||
|
||
// Sanity: View() must emit exactly height lines.
|
||
if len(lines) != 10 {
|
||
t.Fatalf("View() returned %d lines, want 10", len(lines))
|
||
}
|
||
// Item b's first line should appear at viewY=2, NOT viewY=3.
|
||
if !strings.Contains(lines[2], "b-line-0") {
|
||
t.Errorf("viewY=2 should render b-line-0 (empty item contributes 0 rows), got %q", lines[2])
|
||
}
|
||
|
||
// Now the actual hit-test contract: clicking on viewY=2 must map to
|
||
// item b line 0 — the same coordinate View() rendered there.
|
||
idx, line := sl.getItemAndLineAtY(2)
|
||
if idx != 2 || line != 0 {
|
||
t.Errorf("getItemAndLineAtY(2) = (%d,%d), want (2,0)", idx, line)
|
||
}
|
||
|
||
// And clicking on the second line of b (viewY=3) must map to b line 1.
|
||
idx, line = sl.getItemAndLineAtY(3)
|
||
if idx != 2 || line != 1 {
|
||
t.Errorf("getItemAndLineAtY(3) = (%d,%d), want (2,1)", idx, line)
|
||
}
|
||
}
|