feat(copy): carry line bounds into the copy-mode snapshot
- commit
- 8846c59041c0ba380d7ce6604c8f7ba9d17f36f4
- author
- brice · · 2026-07-25 06:16 UTC
- parent
- 5be2475
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +96 -21
M internal/usecase/copy/mode.go +44 -7
| @@ -13,34 +13,55 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | const OSC52MaxPayloadBytes = 75_000 | |
| 15 | 15 | ||
| 16 | - | // Snapshot is the immutable scrollback document: sealed history plus a cloned screen. | |
| 16 | + | // Snapshot is the immutable scrollback document: sealed history plus a cloned | |
| 17 | + | // screen. screenBounds is parallel to screen rows, exactly as the history view's | |
| 18 | + | // own bounds are parallel to its chunk rows. | |
| 17 | 19 | type Snapshot struct { | |
| 18 | 20 | history vt.HistoryView | |
| 19 | 21 | screen renderer.Frame | |
| 22 | + | screenBounds []vt.LineBound | |
| 20 | 23 | Width, Height int | |
| 21 | 24 | } | |
| 22 | 25 | ||
| 23 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 26 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot { | |
| 24 | 27 | var history vt.HistoryView | |
| 25 | 28 | if historySource != nil { | |
| 26 | 29 | history = historySource.SealAndView() | |
| 27 | 30 | } | |
| 28 | - | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 31 | + | return Snapshot{ | |
| 32 | + | history: history, | |
| 33 | + | screen: screen.Clone(), | |
| 34 | + | screenBounds: append([]vt.LineBound(nil), bounds...), | |
| 35 | + | Width: screen.Width, | |
| 36 | + | Height: screen.Height, | |
| 37 | + | } | |
| 29 | 38 | } | |
| 30 | - | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 39 | + | ||
| 40 | + | // NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It | |
| 41 | + | // exists for tests; production code goes through NewSnapshot. | |
| 42 | + | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | |
| 31 | 43 | history := vt.NewHistory(vt.HistoryConfig{ | |
| 32 | - | MaxRows: len(rows), | |
| 44 | + | MaxRows: max(len(rows), 1), | |
| 33 | 45 | MaxCells: snapshotRowsCellBudget(rows), | |
| 34 | 46 | ChunkRows: 256, | |
| 35 | 47 | }) | |
| 36 | - | for _, row := range rows { | |
| 37 | - | if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil { | |
| 48 | + | for i, row := range rows { | |
| 49 | + | bound := vt.LineBound{End: len(row)} | |
| 50 | + | if i < len(bounds) { | |
| 51 | + | bound = bounds[i] | |
| 52 | + | } | |
| 53 | + | if err := history.Append(row, bound); err != nil { | |
| 38 | 54 | panic("copy snapshot: configured history rejected supplied row") | |
| 39 | 55 | } | |
| 40 | 56 | } | |
| 41 | 57 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | |
| 42 | 58 | } | |
| 43 | 59 | ||
| 60 | + | // NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines. | |
| 61 | + | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 62 | + | return NewSnapshotFromLines(rows, nil, width, height) | |
| 63 | + | } | |
| 64 | + | ||
| 44 | 65 | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | |
| 45 | 66 | // Saturation is safe because a larger representable capacity does not exist. | |
| 46 | 67 | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | |
| @@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 67 | 88 | } | |
| 68 | 89 | return s.screen.Row(i) | |
| 69 | 90 | } | |
| 91 | + | ||
| 92 | + | // Bound returns the logical extent of row i, dispatching between sealed history | |
| 93 | + | // and the live screen exactly as Row does. | |
| 94 | + | func (s Snapshot) Bound(i int) vt.LineBound { | |
| 95 | + | if i < 0 { | |
| 96 | + | return vt.LineBound{} | |
| 97 | + | } | |
| 98 | + | if i < s.history.Len() { | |
| 99 | + | return s.history.Bound(i) | |
| 100 | + | } | |
| 101 | + | i -= s.history.Len() | |
| 102 | + | if i >= s.screen.Height || i >= len(s.screenBounds) { | |
| 103 | + | return vt.LineBound{} | |
| 104 | + | } | |
| 105 | + | return s.screenBounds[i] | |
| 106 | + | } | |
| 70 | 107 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |
| 71 | 108 | i := 0 | |
| 72 | 109 | stopped := false | |
| @@ -13,34 +13,55 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | const OSC52MaxPayloadBytes = 75_000 | 14 | const OSC52MaxPayloadBytes = 75_000 | ||
| 15 | 15 | ||||
| 16 | - | // Snapshot is the immutable scrollback document: sealed history plus a cloned screen. | 16 | + | // Snapshot is the immutable scrollback document: sealed history plus a cloned |
| 17 | + | // screen. screenBounds is parallel to screen rows, exactly as the history view's | |||
| 18 | + | // own bounds are parallel to its chunk rows. | |||
| 17 | type Snapshot struct { | 19 | type Snapshot struct { | ||
| 18 | history vt.HistoryView | 20 | history vt.HistoryView | ||
| 19 | screen renderer.Frame | 21 | screen renderer.Frame | ||
| 22 | + | screenBounds []vt.LineBound | |||
| 20 | Width, Height int | 23 | Width, Height int | ||
| 21 | } | 24 | } | ||
| 22 | 25 | ||||
| 23 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | 26 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot { |
| 24 | var history vt.HistoryView | 27 | var history vt.HistoryView | ||
| 25 | if historySource != nil { | 28 | if historySource != nil { | ||
| 26 | history = historySource.SealAndView() | 29 | history = historySource.SealAndView() | ||
| 27 | } | 30 | } | ||
| 28 | - | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 31 | + | return Snapshot{ |
| 32 | + | history: history, | |||
| 33 | + | screen: screen.Clone(), | |||
| 34 | + | screenBounds: append([]vt.LineBound(nil), bounds...), | |||
| 35 | + | Width: screen.Width, | |||
| 36 | + | Height: screen.Height, | |||
| 37 | + | } | |||
| 29 | } | 38 | } | ||
| 30 | - | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | 39 | + | |
| 40 | + | // NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It | |||
| 41 | + | // exists for tests; production code goes through NewSnapshot. | |||
| 42 | + | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | |||
| 31 | history := vt.NewHistory(vt.HistoryConfig{ | 43 | history := vt.NewHistory(vt.HistoryConfig{ | ||
| 32 | - | MaxRows: len(rows), | 44 | + | MaxRows: max(len(rows), 1), |
| 33 | MaxCells: snapshotRowsCellBudget(rows), | 45 | MaxCells: snapshotRowsCellBudget(rows), | ||
| 34 | ChunkRows: 256, | 46 | ChunkRows: 256, | ||
| 35 | }) | 47 | }) | ||
| 36 | - | for _, row := range rows { | 48 | + | for i, row := range rows { |
| 37 | - | if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil { | 49 | + | bound := vt.LineBound{End: len(row)} |
| 50 | + | if i < len(bounds) { | |||
| 51 | + | bound = bounds[i] | |||
| 52 | + | } | |||
| 53 | + | if err := history.Append(row, bound); err != nil { | |||
| 38 | panic("copy snapshot: configured history rejected supplied row") | 54 | panic("copy snapshot: configured history rejected supplied row") | ||
| 39 | } | 55 | } | ||
| 40 | } | 56 | } | ||
| 41 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | 57 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | ||
| 42 | } | 58 | } | ||
| 43 | 59 | ||||
| 60 | + | // NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines. | |||
| 61 | + | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 62 | + | return NewSnapshotFromLines(rows, nil, width, height) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 44 | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | 65 | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | ||
| 45 | // Saturation is safe because a larger representable capacity does not exist. | 66 | // Saturation is safe because a larger representable capacity does not exist. | ||
| 46 | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | 67 | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | ||
| @@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||||
| 67 | } | 88 | } | ||
| 68 | return s.screen.Row(i) | 89 | return s.screen.Row(i) | ||
| 69 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | // Bound returns the logical extent of row i, dispatching between sealed history | |||
| 93 | + | // and the live screen exactly as Row does. | |||
| 94 | + | func (s Snapshot) Bound(i int) vt.LineBound { | |||
| 95 | + | if i < 0 { | |||
| 96 | + | return vt.LineBound{} | |||
| 97 | + | } | |||
| 98 | + | if i < s.history.Len() { | |||
| 99 | + | return s.history.Bound(i) | |||
| 100 | + | } | |||
| 101 | + | i -= s.history.Len() | |||
| 102 | + | if i >= s.screen.Height || i >= len(s.screenBounds) { | |||
| 103 | + | return vt.LineBound{} | |||
| 104 | + | } | |||
| 105 | + | return s.screenBounds[i] | |||
| 106 | + | } | |||
| 70 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | 107 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | ||
| 71 | i := 0 | 108 | i := 0 | ||
| 72 | stopped := false | 109 | stopped := false | ||
M internal/usecase/copy/mode_test.go +39 -1
| @@ -42,6 +42,44 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |
| 46 | + | row := func(s string) []renderer.Cell { | |
| 47 | + | cells := make([]renderer.Cell, 0, len(s)) | |
| 48 | + | for _, r := range s { | |
| 49 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 50 | + | } | |
| 51 | + | return cells | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 55 | + | if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil { | |
| 56 | + | t.Fatalf("append: %v", err) | |
| 57 | + | } | |
| 58 | + | screen := renderer.NewFrame(4, 2) | |
| 59 | + | copy(screen.Row(0), row("live")) | |
| 60 | + | ||
| 61 | + | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}) | |
| 62 | + | ||
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | i int | |
| 66 | + | want vt.LineBound | |
| 67 | + | }{ | |
| 68 | + | {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}}, | |
| 69 | + | {name: "first screen row", i: 1, want: vt.LineBound{End: 4}}, | |
| 70 | + | {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}}, | |
| 71 | + | {name: "past the end", i: 3, want: vt.LineBound{}}, | |
| 72 | + | {name: "negative", i: -1, want: vt.LineBound{}}, | |
| 73 | + | } | |
| 74 | + | for _, tc := range tests { | |
| 75 | + | t.Run(tc.name, func(t *testing.T) { | |
| 76 | + | if got := snap.Bound(tc.i); got != tc.want { | |
| 77 | + | t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 45 | 83 | func TestCopyModeKeyboardSelection(t *testing.T) { | |
| 46 | 84 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | |
| 47 | 85 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | |
| @@ -194,7 +232,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||
| 194 | 232 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | |
| 195 | 233 | } | |
| 196 | 234 | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | |
| 197 | - | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | |
| 235 | + | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil) | |
| 198 | 236 | view := history.SealAndView() | |
| 199 | 237 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | |
| 200 | 238 | ||
| @@ -42,6 +42,44 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||||
| 42 | } | 42 | } | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |||
| 46 | + | row := func(s string) []renderer.Cell { | |||
| 47 | + | cells := make([]renderer.Cell, 0, len(s)) | |||
| 48 | + | for _, r := range s { | |||
| 49 | + | cells = append(cells, renderer.Cell{Rune: r}) | |||
| 50 | + | } | |||
| 51 | + | return cells | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |||
| 55 | + | if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil { | |||
| 56 | + | t.Fatalf("append: %v", err) | |||
| 57 | + | } | |||
| 58 | + | screen := renderer.NewFrame(4, 2) | |||
| 59 | + | copy(screen.Row(0), row("live")) | |||
| 60 | + | ||||
| 61 | + | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}) | |||
| 62 | + | ||||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | i int | |||
| 66 | + | want vt.LineBound | |||
| 67 | + | }{ | |||
| 68 | + | {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}}, | |||
| 69 | + | {name: "first screen row", i: 1, want: vt.LineBound{End: 4}}, | |||
| 70 | + | {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}}, | |||
| 71 | + | {name: "past the end", i: 3, want: vt.LineBound{}}, | |||
| 72 | + | {name: "negative", i: -1, want: vt.LineBound{}}, | |||
| 73 | + | } | |||
| 74 | + | for _, tc := range tests { | |||
| 75 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 76 | + | if got := snap.Bound(tc.i); got != tc.want { | |||
| 77 | + | t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want) | |||
| 78 | + | } | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 45 | func TestCopyModeKeyboardSelection(t *testing.T) { | 83 | func TestCopyModeKeyboardSelection(t *testing.T) { | ||
| 46 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | 84 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | ||
| 47 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | 85 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | ||
| @@ -194,7 +232,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||||
| 194 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | 232 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | ||
| 195 | } | 233 | } | ||
| 196 | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | 234 | require.NoError(t, history.Append(target, vt.LineBound{End: len(target)})) | ||
| 197 | - | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | 235 | + | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil) |
| 198 | view := history.SealAndView() | 236 | view := history.SealAndView() | ||
| 199 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | 237 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | ||
| 200 | 238 | ||||
M internal/usecase/daemon/copy_mouse_input.go +1 -1
| @@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab | |||
| 68 | 68 | geometry.pane.mu.Lock() | |
| 69 | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 70 | 70 | altScreen := geometry.pane.screen.AltScreenActive() | |
| 71 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |
| 71 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds()) | |
| 72 | 72 | geometry.pane.mu.Unlock() | |
| 73 | 73 | if mouseMode != 0 || altScreen { | |
| 74 | 74 | return false // child forwarding retains its existing raw-byte path. | |
| @@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab | |||||
| 68 | geometry.pane.mu.Lock() | 68 | geometry.pane.mu.Lock() | ||
| 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | ||
| 70 | altScreen := geometry.pane.screen.AltScreenActive() | 70 | altScreen := geometry.pane.screen.AltScreenActive() | ||
| 71 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | 71 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds()) |
| 72 | geometry.pane.mu.Unlock() | 72 | geometry.pane.mu.Unlock() | ||
| 73 | if mouseMode != 0 || altScreen { | 73 | if mouseMode != 0 || altScreen { | ||
| 74 | return false // child forwarding retains its existing raw-byte path. | 74 | return false // child forwarding retains its existing raw-byte path. | ||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 74 | 74 | return | |
| 75 | 75 | } | |
| 76 | 76 | p.mu.Lock() | |
| 77 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 77 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) | |
| 78 | 78 | p.mu.Unlock() | |
| 79 | 79 | cfg := d.currentCopyConfig() | |
| 80 | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 74 | return | 74 | return | ||
| 75 | } | 75 | } | ||
| 76 | p.mu.Lock() | 76 | p.mu.Lock() | ||
| 77 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | 77 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) |
| 78 | p.mu.Unlock() | 78 | p.mu.Unlock() | ||
| 79 | cfg := d.currentCopyConfig() | 79 | cfg := d.currentCopyConfig() | ||
| 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 68 | 68 | sess.client = ac | |
| 69 | 69 | ac.setSession(sess) | |
| 70 | 70 | closing.mu.Lock() | |
| 71 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators) | |
| 71 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 72 | 72 | closing.mu.Unlock() | |
| 73 | 73 | ac.overlays.copyMu.Lock() | |
| 74 | 74 | ac.overlays.copyPane = closing | |
| @@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 113 | 113 | tb := sess.activeTab() | |
| 114 | 114 | p := tb.focusedPane() | |
| 115 | 115 | p.mu.Lock() | |
| 116 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 116 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 117 | 117 | p.mu.Unlock() | |
| 118 | 118 | ||
| 119 | 119 | // Pause after candidate publication, then make the captured target stale. | |
| @@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 337 | 337 | tb := sess.activeTab() | |
| 338 | 338 | p := tb.focusedPane() | |
| 339 | 339 | p.mu.Lock() | |
| 340 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 340 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 341 | 341 | p.mu.Unlock() | |
| 342 | 342 | tc.invalidate(sess, tb, p) | |
| 343 | 343 | ||
| @@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 68 | sess.client = ac | 68 | sess.client = ac | ||
| 69 | ac.setSession(sess) | 69 | ac.setSession(sess) | ||
| 70 | closing.mu.Lock() | 70 | closing.mu.Lock() | ||
| 71 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators) | 71 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators) |
| 72 | closing.mu.Unlock() | 72 | closing.mu.Unlock() | ||
| 73 | ac.overlays.copyMu.Lock() | 73 | ac.overlays.copyMu.Lock() | ||
| 74 | ac.overlays.copyPane = closing | 74 | ac.overlays.copyPane = closing | ||
| @@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 113 | tb := sess.activeTab() | 113 | tb := sess.activeTab() | ||
| 114 | p := tb.focusedPane() | 114 | p := tb.focusedPane() | ||
| 115 | p.mu.Lock() | 115 | p.mu.Lock() | ||
| 116 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | 116 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) |
| 117 | p.mu.Unlock() | 117 | p.mu.Unlock() | ||
| 118 | 118 | ||||
| 119 | // Pause after candidate publication, then make the captured target stale. | 119 | // Pause after candidate publication, then make the captured target stale. | ||
| @@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||||
| 337 | tb := sess.activeTab() | 337 | tb := sess.activeTab() | ||
| 338 | p := tb.focusedPane() | 338 | p := tb.focusedPane() | ||
| 339 | p.mu.Lock() | 339 | p.mu.Lock() | ||
| 340 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | 340 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) |
| 341 | p.mu.Unlock() | 341 | p.mu.Unlock() | ||
| 342 | tc.invalidate(sess, tb, p) | 342 | tc.invalidate(sess, tb, p) | ||
| 343 | 343 | ||||
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
| @@ -352,7 +352,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||
| 352 | 352 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 353 | 353 | p := sess.activeTab().focusedPane() | |
| 354 | 354 | p.mu.Lock() | |
| 355 | - | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 355 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 356 | 356 | p.mu.Unlock() | |
| 357 | 357 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 358 | 358 | }}, | |
| @@ -352,7 +352,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||||
| 352 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | 352 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | ||
| 353 | p := sess.activeTab().focusedPane() | 353 | p := sess.activeTab().focusedPane() | ||
| 354 | p.mu.Lock() | 354 | p.mu.Lock() | ||
| 355 | - | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | 355 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) |
| 356 | p.mu.Unlock() | 356 | p.mu.Unlock() | ||
| 357 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | 357 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | ||
| 358 | }}, | 358 | }}, | ||
M internal/usecase/daemon/copymode_test.go +5 -5
| @@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 112 | 112 | tb := sess.activeTab() | |
| 113 | 113 | pane := tb.focusedPane() | |
| 114 | 114 | pane.mu.Lock() | |
| 115 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | 118 | ||
| @@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 146 | 146 | tb := sess.activeTab() | |
| 147 | 147 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 148 | 148 | tb.focusedPane().screen.Write([]byte("live")) | |
| 149 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) | |
| 149 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds()) | |
| 150 | 150 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 151 | 151 | ||
| 152 | 152 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -434,7 +434,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 434 | 434 | } | |
| 435 | 435 | p.mu.Lock() | |
| 436 | 436 | defer p.mu.Unlock() | |
| 437 | - | return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12 | |
| 437 | + | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12 | |
| 438 | 438 | }, 2*time.Second, 5*time.Millisecond) | |
| 439 | 439 | ||
| 440 | 440 | sess := firstSession(d) | |
| @@ -784,7 +784,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 784 | 784 | } | |
| 785 | 785 | fp.screen.Write([]byte("live")) | |
| 786 | 786 | fp.mu.Lock() | |
| 787 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len() | |
| 787 | + | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len() | |
| 788 | 788 | fp.mu.Unlock() | |
| 789 | 789 | ||
| 790 | 790 | d.enterCopyMode(sess, ac) | |
| @@ -894,7 +894,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||
| 894 | 894 | base := renderer.NewFrame(20, 8) | |
| 895 | 895 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 896 | 896 | pane.screen.Write([]byte("copy")) | |
| 897 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | |
| 897 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators)) | |
| 898 | 898 | if tt.selectMode { | |
| 899 | 899 | mode.ToggleLineSelection() | |
| 900 | 900 | } | |
| @@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 112 | tb := sess.activeTab() | 112 | tb := sess.activeTab() | ||
| 113 | pane := tb.focusedPane() | 113 | pane := tb.focusedPane() | ||
| 114 | pane.mu.Lock() | 114 | pane.mu.Lock() | ||
| 115 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()) |
| 116 | pane.mu.Unlock() | 116 | pane.mu.Unlock() | ||
| 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 118 | 118 | ||||
| @@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 146 | tb := sess.activeTab() | 146 | tb := sess.activeTab() | ||
| 147 | tb.focusedPane().screen = vt.NewScreen(12, 3) | 147 | tb.focusedPane().screen = vt.NewScreen(12, 3) | ||
| 148 | tb.focusedPane().screen.Write([]byte("live")) | 148 | tb.focusedPane().screen.Write([]byte("live")) | ||
| 149 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) | 149 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds()) |
| 150 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 150 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 151 | 151 | ||||
| 152 | bars := barState{status: sess.statusSegments(true)} | 152 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -434,7 +434,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 434 | } | 434 | } | ||
| 435 | p.mu.Lock() | 435 | p.mu.Lock() | ||
| 436 | defer p.mu.Unlock() | 436 | defer p.mu.Unlock() | ||
| 437 | - | return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12 | 437 | + | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12 |
| 438 | }, 2*time.Second, 5*time.Millisecond) | 438 | }, 2*time.Second, 5*time.Millisecond) | ||
| 439 | 439 | ||||
| 440 | sess := firstSession(d) | 440 | sess := firstSession(d) | ||
| @@ -784,7 +784,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 784 | } | 784 | } | ||
| 785 | fp.screen.Write([]byte("live")) | 785 | fp.screen.Write([]byte("live")) | ||
| 786 | fp.mu.Lock() | 786 | fp.mu.Lock() | ||
| 787 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len() | 787 | + | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len() |
| 788 | fp.mu.Unlock() | 788 | fp.mu.Unlock() | ||
| 789 | 789 | ||||
| 790 | d.enterCopyMode(sess, ac) | 790 | d.enterCopyMode(sess, ac) | ||
| @@ -894,7 +894,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||||
| 894 | base := renderer.NewFrame(20, 8) | 894 | base := renderer.NewFrame(20, 8) | ||
| 895 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | 895 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | ||
| 896 | pane.screen.Write([]byte("copy")) | 896 | pane.screen.Write([]byte("copy")) | ||
| 897 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | 897 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators)) |
| 898 | if tt.selectMode { | 898 | if tt.selectMode { | ||
| 899 | mode.ToggleLineSelection() | 899 | mode.ToggleLineSelection() | ||
| 900 | } | 900 | } | ||
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 200 | 200 | ac.initOverlays() | |
| 201 | 201 | history := newTestHistory(1) | |
| 202 | 202 | appendHistoryRow(t, history, testRow("before")) | |
| 203 | - | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | |
| 203 | + | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators) | |
| 204 | 204 | ||
| 205 | 205 | ac.overlays.copyMu.Lock() | |
| 206 | 206 | ac.overlays.copyMode = scopy.NewMode(document) | |
| @@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |||||
| 200 | ac.initOverlays() | 200 | ac.initOverlays() | ||
| 201 | history := newTestHistory(1) | 201 | history := newTestHistory(1) | ||
| 202 | appendHistoryRow(t, history, testRow("before")) | 202 | appendHistoryRow(t, history, testRow("before")) | ||
| 203 | - | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | 203 | + | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators) |
| 204 | 204 | ||||
| 205 | ac.overlays.copyMu.Lock() | 205 | ac.overlays.copyMu.Lock() | ||
| 206 | ac.overlays.copyMode = scopy.NewMode(document) | 206 | ac.overlays.copyMode = scopy.NewMode(document) | ||
M internal/usecase/daemon/render_test.go +1 -1
| @@ -1264,7 +1264,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1264 | 1264 | } | |
| 1265 | 1265 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1266 | 1266 | p.screen.Write([]byte("ab\r\ncd")) | |
| 1267 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 1267 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) | |
| 1268 | 1268 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1269 | 1269 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1270 | 1270 | ||
| @@ -1264,7 +1264,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||||
| 1264 | } | 1264 | } | ||
| 1265 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | 1265 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | ||
| 1266 | p.screen.Write([]byte("ab\r\ncd")) | 1266 | p.screen.Write([]byte("ab\r\ncd")) | ||
| 1267 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | 1267 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) |
| 1268 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | 1268 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | ||
| 1269 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | 1269 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | ||
| 1270 | 1270 | ||||