detached 33be6b4

Merge pull request #133 from bnema/fix/copy-mode-text-extraction

fix(copy): return selected text as formatted, not as grid
commit
33be6b45578cea04a96af8cda6f3ed8a4dd7075e
author
bnema · · 2026-07-25 13:04 UTC
committer
GitHub · · 2026-07-25 13:04 UTC
merge of
5a69232 + a6ee214 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
37 files · +1109 -202
M internal/usecase/copy/document.go +63 -20
@@ -237,49 +237,92 @@ func (d *Document) LineText(row int) string {
237237 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
238238 }
239239
240-// Extract returns inclusive ranges joined by physical-row newlines. Line-wise
241-// extraction trims trailing blank spaces; stream extraction preserves them.
242-func (d *Document) Extract(ranges []CellRange, linewise bool) string {
240+// Extract renders inclusive ranges as copyable text. Every emitted line drops
241+// the grid padding to its right, and rows the VT marked as soft-wrapped are
242+// joined only when the next emitted segment is the next physical row.
243+func (d *Document) Extract(ranges []CellRange) string {
243244 if len(ranges) == 0 {
244245 return ""
245246 }
246- lines := make([]string, 0, len(ranges))
247+
248+ // Filter invalid ranges before deciding which segment is final or adjacent.
249+ // rangeText with logicalEnd=true performs the same normalization and bounds
250+ // checks while deliberately trimming, but this validation text is discarded.
251+ emitted := make([]CellRange, 0, len(ranges))
247252 for _, r := range ranges {
248- text, ok := d.rangeText(r)
249- if !ok {
250- continue
253+ if _, _, ok := d.rangeText(r, true); ok {
254+ emitted = append(emitted, r)
251255 }
252- if linewise {
253- text = strings.TrimRight(text, " ")
256+ }
257+
258+ var out strings.Builder
259+ for i, r := range emitted {
260+ last := i == len(emitted)-1
261+ adjacent := !last && emitted[i+1].Row == r.Row+1
262+ text, soft, _ := d.rangeText(r, last || !adjacent)
263+ out.WriteString(text)
264+ if !last && !soft {
265+ out.WriteByte('\n')
254266 }
255- lines = append(lines, text)
256267 }
257- return strings.Join(lines, "\n")
268+ return out.String()
258269 }
259270
260-func (d *Document) rangeText(r CellRange) (string, bool) {
271+// rangeText renders one physical row of a selection and reports whether that
272+// row continues into the next emitted physical row. A row that continues is cut
273+// at its logical extent, keeping trailing spaces the application really printed;
274+// any logical end is cut after its last content cell, dropping grid padding.
275+func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
261276 if r.Row < 0 || r.Row >= d.Len() {
262- return "", false
277+ return "", false, false
263278 }
264279 row := d.Row(r.Row)
265280 if len(row) == 0 {
266- return "", r.Start == 0 && r.End == 0
281+ return "", false, r.Start == 0 && r.End == 0
267282 }
268283 start, end := min(r.Start, r.End), max(r.Start, r.End)
269284 if end < 0 || start >= len(row) {
270- return "", false
285+ return "", false, false
271286 }
272287 start = max(start, 0)
273288 end = min(end, len(row)-1)
274- headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start})
289+
290+ head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
275291 if !ok {
276- return "", false
292+ return "", false, false
293+ }
294+ start = head.Col
295+
296+ bound := d.snapshot.Bound(r.Row)
297+ soft := bound.Soft && !logicalEnd && end >= len(row)-1
298+ if soft {
299+ end = min(end, min(max(bound.End, 0), len(row))-1)
300+ } else {
301+ end = lastContentCol(row, start, end)
277302 }
278- headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end})
303+ if end < start {
304+ return "", soft, true
305+ }
306+
307+ tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
279308 if !ok {
280- return "", false
309+ return "", soft, true
310+ }
311+ return d.cellsText(row, start, tail.Col), soft, true
312+}
313+
314+// lastContentCol returns the rightmost column in [start, end] holding content,
315+// or start-1 when the span is blank. Continuation cells belong to the glyph on
316+// their left, so they never count on their own.
317+func lastContentCol(row []renderer.Cell, start, end int) int {
318+ for col := end; col >= start; col-- {
319+ cell := row[col]
320+ if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
321+ continue
322+ }
323+ return col
281324 }
282- return d.cellsText(row, headStart.Col, headEnd.Col), true
325+ return start - 1
283326 }
284327
285328 func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
@@ -237,49 +237,92 @@ func (d *Document) LineText(row int) string {
237 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")237 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
238 }238 }
239 239
240-// Extract returns inclusive ranges joined by physical-row newlines. Line-wise240+// Extract renders inclusive ranges as copyable text. Every emitted line drops
241-// extraction trims trailing blank spaces; stream extraction preserves them.241+// the grid padding to its right, and rows the VT marked as soft-wrapped are
242-func (d *Document) Extract(ranges []CellRange, linewise bool) string {242+// joined only when the next emitted segment is the next physical row.
243+func (d *Document) Extract(ranges []CellRange) string {
243 if len(ranges) == 0 {244 if len(ranges) == 0 {
244 return ""245 return ""
245 }246 }
246- lines := make([]string, 0, len(ranges))247+
248+ // Filter invalid ranges before deciding which segment is final or adjacent.
249+ // rangeText with logicalEnd=true performs the same normalization and bounds
250+ // checks while deliberately trimming, but this validation text is discarded.
251+ emitted := make([]CellRange, 0, len(ranges))
247 for _, r := range ranges {252 for _, r := range ranges {
248- text, ok := d.rangeText(r)253+ if _, _, ok := d.rangeText(r, true); ok {
249- if !ok {254+ emitted = append(emitted, r)
250- continue
251 }255 }
252- if linewise {256+ }
253- text = strings.TrimRight(text, " ")257+
258+ var out strings.Builder
259+ for i, r := range emitted {
260+ last := i == len(emitted)-1
261+ adjacent := !last && emitted[i+1].Row == r.Row+1
262+ text, soft, _ := d.rangeText(r, last || !adjacent)
263+ out.WriteString(text)
264+ if !last && !soft {
265+ out.WriteByte('\n')
254 }266 }
255- lines = append(lines, text)
256 }267 }
257- return strings.Join(lines, "\n")268+ return out.String()
258 }269 }
259 270
260-func (d *Document) rangeText(r CellRange) (string, bool) {271+// rangeText renders one physical row of a selection and reports whether that
272+// row continues into the next emitted physical row. A row that continues is cut
273+// at its logical extent, keeping trailing spaces the application really printed;
274+// any logical end is cut after its last content cell, dropping grid padding.
275+func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
261 if r.Row < 0 || r.Row >= d.Len() {276 if r.Row < 0 || r.Row >= d.Len() {
262- return "", false277+ return "", false, false
263 }278 }
264 row := d.Row(r.Row)279 row := d.Row(r.Row)
265 if len(row) == 0 {280 if len(row) == 0 {
266- return "", r.Start == 0 && r.End == 0281+ return "", false, r.Start == 0 && r.End == 0
267 }282 }
268 start, end := min(r.Start, r.End), max(r.Start, r.End)283 start, end := min(r.Start, r.End), max(r.Start, r.End)
269 if end < 0 || start >= len(row) {284 if end < 0 || start >= len(row) {
270- return "", false285+ return "", false, false
271 }286 }
272 start = max(start, 0)287 start = max(start, 0)
273 end = min(end, len(row)-1)288 end = min(end, len(row)-1)
274- headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start})289+
290+ head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
275 if !ok {291 if !ok {
276- return "", false292+ return "", false, false
293+ }
294+ start = head.Col
295+
296+ bound := d.snapshot.Bound(r.Row)
297+ soft := bound.Soft && !logicalEnd && end >= len(row)-1
298+ if soft {
299+ end = min(end, min(max(bound.End, 0), len(row))-1)
300+ } else {
301+ end = lastContentCol(row, start, end)
277 }302 }
278- headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end})303+ if end < start {
304+ return "", soft, true
305+ }
306+
307+ tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
279 if !ok {308 if !ok {
280- return "", false309+ return "", soft, true
310+ }
311+ return d.cellsText(row, start, tail.Col), soft, true
312+}
313+
314+// lastContentCol returns the rightmost column in [start, end] holding content,
315+// or start-1 when the span is blank. Continuation cells belong to the glyph on
316+// their left, so they never count on their own.
317+func lastContentCol(row []renderer.Cell, start, end int) int {
318+ for col := end; col >= start; col-- {
319+ cell := row[col]
320+ if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
321+ continue
322+ }
323+ return col
281 }324 }
282- return d.cellsText(row, headStart.Col, headEnd.Col), true325+ return start - 1
283 }326 }
284 327
285 func (d *Document) cellsText(row []renderer.Cell, start, end int) string {328 func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
M internal/usecase/copy/document_test.go +134 -12
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/pkg/renderer"
7+ "github.com/bnema/vev/pkg/vt"
78 "github.com/stretchr/testify/require"
89 )
910
@@ -216,22 +217,143 @@ func TestDocumentExtractRanges(t *testing.T) {
216217 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
217218
218219 tests := []struct {
219- name string
220- ranges []CellRange
221- linewise bool
222- want string
220+ name string
221+ ranges []CellRange
222+ want string
223223 }{
224- {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"},
225- {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"},
226- {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"},
227- {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"},
228- {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""},
229- {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""},
230- {"no ranges", nil, false, ""},
224+ {"inclusive partial multiline drops each row's padding", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, "pha\nbe"},
225+ {"whole row drops trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, "alpha"},
226+ {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, "界"},
227+ {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, "界"},
228+ {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, ""},
229+ {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, ""},
230+ {"no ranges", nil, ""},
231231 }
232232 for _, tt := range tests {
233233 t.Run(tt.name, func(t *testing.T) {
234- require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise))
234+ require.Equal(t, tt.want, doc.Extract(tt.ranges))
235+ })
236+ }
237+}
238+
239+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240+ // pad renders s into a width-w row, blank-filled like a real terminal grid.
241+ pad := func(s string, w int) []renderer.Cell {
242+ cells := make([]renderer.Cell, w)
243+ for i := range cells {
244+ cells[i] = renderer.BlankCell()
245+ }
246+ for i, r := range []rune(s) {
247+ if i >= w {
248+ break
249+ }
250+ cells[i] = renderer.Cell{Rune: r}
251+ }
252+ return cells
253+ }
254+
255+ tests := []struct {
256+ name string
257+ rows [][]renderer.Cell
258+ bounds []vt.LineBound
259+ ranges []CellRange
260+ want string
261+ }{
262+ {
263+ name: "hard row drops its padding",
264+ rows: [][]renderer.Cell{pad("ab", 8)},
265+ bounds: []vt.LineBound{{End: 2}},
266+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267+ want: "ab",
268+ },
269+ {
270+ name: "soft row joins without a newline",
271+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
272+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274+ want: "abcdefghij",
275+ },
276+ {
277+ name: "soft row keeps a real trailing space",
278+ rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
279+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281+ want: "abc de",
282+ },
283+ {
284+ name: "three consecutive soft rows",
285+ rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
286+ bounds: []vt.LineBound{
287+ {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288+ },
289+ ranges: []CellRange{
290+ {Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
291+ {Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
292+ },
293+ want: "aaaabbbbccccdd",
294+ },
295+ {
296+ name: "hard rows keep their newline",
297+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
298+ bounds: []vt.LineBound{{End: 2}, {End: 2}},
299+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300+ want: "ab\ncd",
301+ },
302+ {
303+ name: "sparse ranges do not join across a row gap",
304+ rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
305+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307+ want: "ab\ncd",
308+ },
309+ {
310+ name: "invalid trailing range does not prevent final emitted row trimming",
311+ rows: [][]renderer.Cell{pad("ab", 8)},
312+ bounds: []vt.LineBound{{End: 8, Soft: true}},
313+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314+ want: "ab",
315+ },
316+ {
317+ name: "blank row survives as an empty line",
318+ rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
319+ bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321+ want: "ab\n\ncd",
322+ },
323+ {
324+ name: "the last row is trimmed even when soft",
325+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
326+ bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328+ want: "ab\ncd",
329+ },
330+ {
331+ name: "wide glyph pushed off a soft row's edge",
332+ // "a界" occupies columns 0..2; the wide rune did not fit at column 3,
333+ // so End stops at 3 and column 3 is abandoned padding.
334+ rows: [][]renderer.Cell{
335+ {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336+ pad("b", 4),
337+ },
338+ bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339+ ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
340+ want: "a界b",
341+ },
342+ {
343+ name: "selection stopping mid soft row",
344+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
345+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346+ ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347+ want: "cde",
348+ },
349+ }
350+
351+ for _, tc := range tests {
352+ t.Run(tc.name, func(t *testing.T) {
353+ doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
354+ if got := doc.Extract(tc.ranges); got != tc.want {
355+ t.Errorf("Extract() = %q, want %q", got, tc.want)
356+ }
235357 })
236358 }
237359 }
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7+ "github.com/bnema/vev/pkg/vt"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
@@ -216,22 +217,143 @@ func TestDocumentExtractRanges(t *testing.T) {
216 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")217 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
217 218
218 tests := []struct {219 tests := []struct {
219- name string220+ name string
220- ranges []CellRange221+ ranges []CellRange
221- linewise bool222+ want string
222- want string
223 }{223 }{
224- {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"},224+ {"inclusive partial multiline drops each row's padding", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, "pha\nbe"},
225- {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"},225+ {"whole row drops trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, "alpha"},
226- {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"},226+ {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, "界"},
227- {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"},227+ {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, "界"},
228- {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""},228+ {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, ""},
229- {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""},229+ {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, ""},
230- {"no ranges", nil, false, ""},230+ {"no ranges", nil, ""},
231 }231 }
232 for _, tt := range tests {232 for _, tt := range tests {
233 t.Run(tt.name, func(t *testing.T) {233 t.Run(tt.name, func(t *testing.T) {
234- require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise))234+ require.Equal(t, tt.want, doc.Extract(tt.ranges))
235+ })
236+ }
237+}
238+
239+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240+ // pad renders s into a width-w row, blank-filled like a real terminal grid.
241+ pad := func(s string, w int) []renderer.Cell {
242+ cells := make([]renderer.Cell, w)
243+ for i := range cells {
244+ cells[i] = renderer.BlankCell()
245+ }
246+ for i, r := range []rune(s) {
247+ if i >= w {
248+ break
249+ }
250+ cells[i] = renderer.Cell{Rune: r}
251+ }
252+ return cells
253+ }
254+
255+ tests := []struct {
256+ name string
257+ rows [][]renderer.Cell
258+ bounds []vt.LineBound
259+ ranges []CellRange
260+ want string
261+ }{
262+ {
263+ name: "hard row drops its padding",
264+ rows: [][]renderer.Cell{pad("ab", 8)},
265+ bounds: []vt.LineBound{{End: 2}},
266+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267+ want: "ab",
268+ },
269+ {
270+ name: "soft row joins without a newline",
271+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
272+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274+ want: "abcdefghij",
275+ },
276+ {
277+ name: "soft row keeps a real trailing space",
278+ rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
279+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281+ want: "abc de",
282+ },
283+ {
284+ name: "three consecutive soft rows",
285+ rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
286+ bounds: []vt.LineBound{
287+ {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288+ },
289+ ranges: []CellRange{
290+ {Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
291+ {Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
292+ },
293+ want: "aaaabbbbccccdd",
294+ },
295+ {
296+ name: "hard rows keep their newline",
297+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
298+ bounds: []vt.LineBound{{End: 2}, {End: 2}},
299+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300+ want: "ab\ncd",
301+ },
302+ {
303+ name: "sparse ranges do not join across a row gap",
304+ rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
305+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307+ want: "ab\ncd",
308+ },
309+ {
310+ name: "invalid trailing range does not prevent final emitted row trimming",
311+ rows: [][]renderer.Cell{pad("ab", 8)},
312+ bounds: []vt.LineBound{{End: 8, Soft: true}},
313+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314+ want: "ab",
315+ },
316+ {
317+ name: "blank row survives as an empty line",
318+ rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
319+ bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321+ want: "ab\n\ncd",
322+ },
323+ {
324+ name: "the last row is trimmed even when soft",
325+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
326+ bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328+ want: "ab\ncd",
329+ },
330+ {
331+ name: "wide glyph pushed off a soft row's edge",
332+ // "a界" occupies columns 0..2; the wide rune did not fit at column 3,
333+ // so End stops at 3 and column 3 is abandoned padding.
334+ rows: [][]renderer.Cell{
335+ {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336+ pad("b", 4),
337+ },
338+ bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339+ ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
340+ want: "a界b",
341+ },
342+ {
343+ name: "selection stopping mid soft row",
344+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
345+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346+ ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347+ want: "cde",
348+ },
349+ }
350+
351+ for _, tc := range tests {
352+ t.Run(tc.name, func(t *testing.T) {
353+ doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
354+ if got := doc.Extract(tc.ranges); got != tc.want {
355+ t.Errorf("Extract() = %q, want %q", got, tc.want)
356+ }
235 })357 })
236 }358 }
237 }359 }
M internal/usecase/copy/mode.go +44 -7
@@ -13,34 +13,55 @@ import (
1313
1414 const OSC52MaxPayloadBytes = 75_000
1515
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.
1719 type Snapshot struct {
1820 history vt.HistoryView
1921 screen renderer.Frame
22+ screenBounds []vt.LineBound
2023 Width, Height int
2124 }
2225
23-func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
26+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {
2427 var history vt.HistoryView
2528 if historySource != nil {
2629 history = historySource.SealAndView()
2730 }
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+ }
2938 }
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 {
3143 history := vt.NewHistory(vt.HistoryConfig{
32- MaxRows: len(rows),
44+ MaxRows: max(len(rows), 1),
3345 MaxCells: snapshotRowsCellBudget(rows),
3446 ChunkRows: 256,
3547 })
36- for _, row := range rows {
37- if err := history.Append(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 {
3854 panic("copy snapshot: configured history rejected supplied row")
3955 }
4056 }
4157 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
4258 }
4359
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+
4465 // snapshotRowsCellBudget returns a capacity that retains every supplied row.
4566 // Saturation is safe because a larger representable capacity does not exist.
4667 func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
@@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell {
6788 }
6889 return s.screen.Row(i)
6990 }
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+}
70107 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
71108 i := 0
72109 stopped := false
@@ -13,34 +13,55 @@ import (
13 13
14 const OSC52MaxPayloadBytes = 75_00014 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.HistoryView20 history vt.HistoryView
19 screen renderer.Frame21 screen renderer.Frame
22+ screenBounds []vt.LineBound
20 Width, Height int23 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.HistoryView27 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); 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 := 0108 i := 0
72 stopped := false109 stopped := false
M internal/usecase/copy/mode_test.go +149 -3
@@ -42,6 +42,44 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
4242 }
4343 }
4444
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+
4583 func TestCopyModeKeyboardSelection(t *testing.T) {
4684 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
4785 require.Equal(t, Pos{Row: 3}, m.Cursor())
@@ -189,11 +227,12 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
189227 func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
190228 const rows = 10_000
191229 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
230+ unmatched, target := row("unmatched"), row("target")
192231 for range rows {
193- require.NoError(t, history.Append(row("unmatched")))
232+ require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
194233 }
195- require.NoError(t, history.Append(row("target")))
196- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
234+ require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
235+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)
197236 view := history.SealAndView()
198237 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
199238
@@ -280,3 +319,110 @@ func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
280319
281320 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
282321 }
322+
323+func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
324+ row := func(s string, w int) []renderer.Cell {
325+ cells := make([]renderer.Cell, w)
326+ for i := range cells {
327+ cells[i] = renderer.BlankCell()
328+ }
329+ for i, r := range []rune(s) {
330+ if i >= w {
331+ break
332+ }
333+ cells[i] = renderer.Cell{Rune: r}
334+ }
335+ return cells
336+ }
337+
338+ const width = 4
339+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
340+ // The last history row wrapped into what is now the first live screen row.
341+ if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
342+ t.Fatalf("append: %v", err)
343+ }
344+ screen := renderer.NewFrame(width, 2)
345+ copy(screen.Row(0), row("ef", width))
346+ copy(screen.Row(1), row("gh", width))
347+
348+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})
349+ doc := NewDocument(snapshot, "")
350+
351+ ranges := []CellRange{
352+ {Row: 0, Start: 0, End: width - 1},
353+ {Row: 1, Start: 0, End: width - 1},
354+ {Row: 2, Start: 0, End: width - 1},
355+ }
356+ const want = "abcdef\ngh"
357+ if got := doc.Extract(ranges); got != want {
358+ t.Errorf("Extract() = %q, want %q", got, want)
359+ }
360+}
361+
362+// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
363+// hand-written bounds: a real vt.Screen wraps one logical line itself, its
364+// LineBound travels through eviction into history or stays on the live grid,
365+// and Extract must give the line back exactly as the application printed it.
366+func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
367+ const (
368+ width = 8
369+ height = 4
370+ line = "abcdefghij"
371+ )
372+ cases := []struct {
373+ name string
374+ // input is written verbatim to the VT; the wrap and every scroll it
375+ // implies are the emulator's own decisions.
376+ input string
377+ // wantHistoryLen pins where the history/screen seam falls, so a change
378+ // in scroll behavior fails loudly instead of silently retargeting the
379+ // case onto rows the seam no longer separates.
380+ wantHistoryLen int
381+ // wrapRow is the first snapshot row of the wrapped logical line.
382+ wrapRow int
383+ }{
384+ {
385+ name: "wrapped line straddles the history and screen seam",
386+ input: line + "\r\nx\r\ny\r\n",
387+ wantHistoryLen: 1,
388+ wrapRow: 0,
389+ },
390+ {
391+ name: "wrapped line sits entirely in sealed history",
392+ input: line + "\r\nx\r\ny\r\nz\r\n",
393+ wantHistoryLen: 2,
394+ wrapRow: 0,
395+ },
396+ {
397+ name: "wrapped line sits entirely on the live screen",
398+ input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
399+ wantHistoryLen: 2,
400+ wrapRow: 3,
401+ },
402+ {
403+ // The common case: the pane is full, so the cursor is on the last
404+ // row and the wrap scrolls the screen as it happens. This is what a
405+ // long line printed at a shell prompt actually does.
406+ name: "wrapped line wraps out of the bottom row of a full pane",
407+ input: "1\r\n2\r\n3\r\n4\r\n5\r\n" + line,
408+ wantHistoryLen: 3,
409+ wrapRow: 5,
410+ },
411+ }
412+ for _, tc := range cases {
413+ t.Run(tc.name, func(t *testing.T) {
414+ screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
415+ screen.Write([]byte(tc.input))
416+
417+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())
418+ require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
419+ doc := NewDocument(snapshot, domain.DefaultWordSeparators)
420+
421+ got := doc.Extract([]CellRange{
422+ {Row: tc.wrapRow, Start: 0, End: width - 1},
423+ {Row: tc.wrapRow + 1, Start: 0, End: width - 1},
424+ })
425+ require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
426+ })
427+ }
428+}
@@ -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())
@@ -189,11 +227,12 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
189 func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {227 func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
190 const rows = 10_000228 const rows = 10_000
191 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})229 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
230+ unmatched, target := row("unmatched"), row("target")
192 for range rows {231 for range rows {
193- require.NoError(t, history.Append(row("unmatched")))232+ require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
194 }233 }
195- require.NoError(t, history.Append(row("target")))234+ require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
196- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))235+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)
197 view := history.SealAndView()236 view := history.SealAndView()
198 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))237 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
199 238
@@ -280,3 +319,110 @@ func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
280 319
281 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")320 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
282 }321 }
322+
323+func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
324+ row := func(s string, w int) []renderer.Cell {
325+ cells := make([]renderer.Cell, w)
326+ for i := range cells {
327+ cells[i] = renderer.BlankCell()
328+ }
329+ for i, r := range []rune(s) {
330+ if i >= w {
331+ break
332+ }
333+ cells[i] = renderer.Cell{Rune: r}
334+ }
335+ return cells
336+ }
337+
338+ const width = 4
339+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
340+ // The last history row wrapped into what is now the first live screen row.
341+ if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
342+ t.Fatalf("append: %v", err)
343+ }
344+ screen := renderer.NewFrame(width, 2)
345+ copy(screen.Row(0), row("ef", width))
346+ copy(screen.Row(1), row("gh", width))
347+
348+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})
349+ doc := NewDocument(snapshot, "")
350+
351+ ranges := []CellRange{
352+ {Row: 0, Start: 0, End: width - 1},
353+ {Row: 1, Start: 0, End: width - 1},
354+ {Row: 2, Start: 0, End: width - 1},
355+ }
356+ const want = "abcdef\ngh"
357+ if got := doc.Extract(ranges); got != want {
358+ t.Errorf("Extract() = %q, want %q", got, want)
359+ }
360+}
361+
362+// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
363+// hand-written bounds: a real vt.Screen wraps one logical line itself, its
364+// LineBound travels through eviction into history or stays on the live grid,
365+// and Extract must give the line back exactly as the application printed it.
366+func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
367+ const (
368+ width = 8
369+ height = 4
370+ line = "abcdefghij"
371+ )
372+ cases := []struct {
373+ name string
374+ // input is written verbatim to the VT; the wrap and every scroll it
375+ // implies are the emulator's own decisions.
376+ input string
377+ // wantHistoryLen pins where the history/screen seam falls, so a change
378+ // in scroll behavior fails loudly instead of silently retargeting the
379+ // case onto rows the seam no longer separates.
380+ wantHistoryLen int
381+ // wrapRow is the first snapshot row of the wrapped logical line.
382+ wrapRow int
383+ }{
384+ {
385+ name: "wrapped line straddles the history and screen seam",
386+ input: line + "\r\nx\r\ny\r\n",
387+ wantHistoryLen: 1,
388+ wrapRow: 0,
389+ },
390+ {
391+ name: "wrapped line sits entirely in sealed history",
392+ input: line + "\r\nx\r\ny\r\nz\r\n",
393+ wantHistoryLen: 2,
394+ wrapRow: 0,
395+ },
396+ {
397+ name: "wrapped line sits entirely on the live screen",
398+ input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
399+ wantHistoryLen: 2,
400+ wrapRow: 3,
401+ },
402+ {
403+ // The common case: the pane is full, so the cursor is on the last
404+ // row and the wrap scrolls the screen as it happens. This is what a
405+ // long line printed at a shell prompt actually does.
406+ name: "wrapped line wraps out of the bottom row of a full pane",
407+ input: "1\r\n2\r\n3\r\n4\r\n5\r\n" + line,
408+ wantHistoryLen: 3,
409+ wrapRow: 5,
410+ },
411+ }
412+ for _, tc := range cases {
413+ t.Run(tc.name, func(t *testing.T) {
414+ screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
415+ screen.Write([]byte(tc.input))
416+
417+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())
418+ require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
419+ doc := NewDocument(snapshot, domain.DefaultWordSeparators)
420+
421+ got := doc.Extract([]CellRange{
422+ {Row: tc.wrapRow, Start: 0, End: width - 1},
423+ {Row: tc.wrapRow + 1, Start: 0, End: width - 1},
424+ })
425+ require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
426+ })
427+ }
428+}
M internal/usecase/copy/selection.go +3 -3
@@ -137,13 +137,13 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
137137 return CellRange{Row: row, Start: start, End: end}, true
138138 }
139139
140-// Text extracts the selection using its range granularity's newline and
141-// trailing-space semantics.
140+// Text extracts the selection's ranges. The granularity is already encoded in
141+// those ranges, so extraction applies the same padding and wrap rules to all.
142142 func (s Selection) Text(doc *Document) string {
143143 if doc == nil {
144144 return ""
145145 }
146- return doc.Extract(s.Ranges(doc), s.Granularity == Line)
146+ return doc.Extract(s.Ranges(doc))
147147 }
148148
149149 // Extend moves the active endpoint. Document normalization occurs when ranges
@@ -137,13 +137,13 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
137 return CellRange{Row: row, Start: start, End: end}, true137 return CellRange{Row: row, Start: start, End: end}, true
138 }138 }
139 139
140-// Text extracts the selection using its range granularity's newline and140+// Text extracts the selection's ranges. The granularity is already encoded in
141-// trailing-space semantics.141+// those ranges, so extraction applies the same padding and wrap rules to all.
142 func (s Selection) Text(doc *Document) string {142 func (s Selection) Text(doc *Document) string {
143 if doc == nil {143 if doc == nil {
144 return ""144 return ""
145 }145 }
146- return doc.Extract(s.Ranges(doc), s.Granularity == Line)146+ return doc.Extract(s.Ranges(doc))
147 }147 }
148 148
149 // Extend moves the active endpoint. Document normalization occurs when ranges149 // Extend moves the active endpoint. Document normalization occurs when ranges
M internal/usecase/daemon/copy_mouse_input.go +1 -1
@@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab
6868 geometry.pane.mu.Lock()
6969 mouseMode, _ := geometry.pane.screen.MouseMode()
7070 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())
7272 geometry.pane.mu.Unlock()
7373 if mouseMode != 0 || altScreen {
7474 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) {
7474 return
7575 }
7676 p.mu.Lock()
77- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
77+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
7878 p.mu.Unlock()
7979 cfg := d.currentCopyConfig()
8080 document := scopy.NewDocument(snapshot, cfg.WordSeparators)
@@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
74 return74 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) {
6868 sess.client = ac
6969 ac.setSession(sess)
7070 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)
7272 closing.mu.Unlock()
7373 ac.overlays.copyMu.Lock()
7474 ac.overlays.copyPane = closing
@@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
113113 tb := sess.activeTab()
114114 p := tb.focusedPane()
115115 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)
117117 p.mu.Unlock()
118118
119119 // Pause after candidate publication, then make the captured target stale.
@@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
337337 tb := sess.activeTab()
338338 p := tb.focusedPane()
339339 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)
341341 p.mu.Unlock()
342342 tc.invalidate(sess, tb, p)
343343
@@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
68 sess.client = ac68 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 = closing74 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 +2 -4
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "strconv"
5- "strings"
65 "testing"
76 "time"
87
@@ -273,9 +272,8 @@ func TestCopyWordDragAndOSC52(t *testing.T) {
273272 mode := ac.overlays.copyMode
274273 selection := mode.Selection()
275274 text := mode.SelectedText()
276- width := mode.Document().Width()
277275 ac.overlays.copyMu.Unlock()
278- want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma"
276+ want := "alpha beta\ngamma"
279277 require.Equal(t, scopy.Word, selection.Granularity)
280278 require.Equal(t, want, text)
281279 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
@@ -352,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
352350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
353351 p := sess.activeTab().focusedPane()
354352 p.mu.Lock()
355- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
353+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
356354 p.mu.Unlock()
357355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
358356 }},
@@ -2,7 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "strconv"4 "strconv"
5- "strings"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -273,9 +272,8 @@ func TestCopyWordDragAndOSC52(t *testing.T) {
273 mode := ac.overlays.copyMode272 mode := ac.overlays.copyMode
274 selection := mode.Selection()273 selection := mode.Selection()
275 text := mode.SelectedText()274 text := mode.SelectedText()
276- width := mode.Document().Width()
277 ac.overlays.copyMu.Unlock()275 ac.overlays.copyMu.Unlock()
278- want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma"276+ want := "alpha beta\ngamma"
279 require.Equal(t, scopy.Word, selection.Granularity)277 require.Equal(t, scopy.Word, selection.Granularity)
280 require.Equal(t, want, text)278 require.Equal(t, want, text)
281 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])279 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
@@ -352,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
352 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
353 p := sess.activeTab().focusedPane()351 p := sess.activeTab().focusedPane()
354 p.mu.Lock()352 p.mu.Lock()
355- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)353+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
356 p.mu.Unlock()354 p.mu.Unlock()
357 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
358 }},356 }},
M internal/usecase/daemon/copymode_test.go +15 -15
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112112 tb := sess.activeTab()
113113 pane := tb.focusedPane()
114114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118118
@@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
146146 tb := sess.activeTab()
147147 tb.focusedPane().screen = vt.NewScreen(12, 3)
148148 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())
150150 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
151151
152152 bars := barState{status: sess.statusSegments(true)}
@@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
301301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
302302 pane := sess.tabs[0].focusedPane()
303303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
304- require.NoError(t, pane.history.Append(testRow("old alpha")))
304+ appendHistoryRow(t, pane.history, testRow("old alpha"))
305305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
306306
307307 d.enterCopyMode(sess, ac)
@@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
330330 p, _ := newBlockingPTYWithWrites(t, writes)
331331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
332332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
333- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
334- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
333+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
334+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
335335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
336336
337337 d.enterCopyMode(sess, ac)
@@ -434,7 +434,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
434434 }
435435 p.mu.Lock()
436436 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
438438 }, 2*time.Second, 5*time.Millisecond)
439439
440440 sess := firstSession(d)
@@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
505505 p, _ := newBlockingPTY(t)
506506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
508- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))
509- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))
508+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
509+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
510510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
511511
512512 d.enterCopyMode(sess, ac)
@@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
739739 var want *pane
740740 if tc.useFloating {
741741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
742- require.NoError(t, want.history.Append(testRow("flt-old")))
742+ appendHistoryRow(t, want.history, testRow("flt-old"))
743743 want.screen.Write([]byte("flt-live"))
744744 } else {
745745 want = main
746- require.NoError(t, want.history.Append(testRow("one-old")))
746+ appendHistoryRow(t, want.history, testRow("one-old"))
747747 want.screen.Write([]byte("one-live"))
748748 }
749749
@@ -780,11 +780,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
780780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
781781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
782782 for i := range 30 {
783- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))))
783+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i)))
784784 }
785785 fp.screen.Write([]byte("live"))
786786 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()
788788 fp.mu.Unlock()
789789
790790 d.enterCopyMode(sess, ac)
@@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
806806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
807807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
808808 for i := range 5 {
809- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i))))
809+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i)))
810810 }
811811 fp.screen.Write([]byte("live"))
812812
@@ -877,7 +877,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
877877 out := awaitFrame(t, sends, ports.MsgOutput)
878878 msg, err := ports.UnmarshalOutput(out.Payload)
879879 require.NoError(t, err)
880- want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
880+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nb")) + "\x07"
881881 require.Equal(t, want, string(msg.Data))
882882 }
883883
@@ -894,7 +894,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
894894 base := renderer.NewFrame(20, 8)
895895 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
896896 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))
898898 if tt.selectMode {
899899 mode.ToggleLineSelection()
900900 }
@@ -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)}
@@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)301 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
302 pane := sess.tabs[0].focusedPane()302 pane := sess.tabs[0].focusedPane()
303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})303 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
304- require.NoError(t, pane.history.Append(testRow("old alpha")))304+ appendHistoryRow(t, pane.history, testRow("old alpha"))
305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))305 copy(pane.screen.Frame.Row(0), testRow("live alpha"))
306 306
307 d.enterCopyMode(sess, ac)307 d.enterCopyMode(sess, ac)
@@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
330 p, _ := newBlockingPTYWithWrites(t, writes)330 p, _ := newBlockingPTYWithWrites(t, writes)
331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)331 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})332 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
333- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))333+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
334- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))334+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))335 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
336 336
337 d.enterCopyMode(sess, ac)337 d.enterCopyMode(sess, ac)
@@ -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() >= 12437+ 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)
@@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
505 p, _ := newBlockingPTY(t)505 p, _ := newBlockingPTY(t)
506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})507 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
508- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old1 ")))508+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old1 "))
509- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("old2 ")))509+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("old2 "))
510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))510 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
511 511
512 d.enterCopyMode(sess, ac)512 d.enterCopyMode(sess, ac)
@@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
739 var want *pane739 var want *pane
740 if tc.useFloating {740 if tc.useFloating {
741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})741 want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
742- require.NoError(t, want.history.Append(testRow("flt-old")))742+ appendHistoryRow(t, want.history, testRow("flt-old"))
743 want.screen.Write([]byte("flt-live"))743 want.screen.Write([]byte("flt-live"))
744 } else {744 } else {
745 want = main745 want = main
746- require.NoError(t, want.history.Append(testRow("one-old")))746+ appendHistoryRow(t, want.history, testRow("one-old"))
747 want.screen.Write([]byte("one-live"))747 want.screen.Write([]byte("one-live"))
748 }748 }
749 749
@@ -780,11 +780,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)780 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})781 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
782 for i := range 30 {782 for i := range 30 {
783- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%02d", i))))783+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%02d", i)))
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)
@@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)806 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})807 fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
808 for i := range 5 {808 for i := range 5 {
809- require.NoError(t, fp.history.Append(testRow(fmt.Sprintf("old-%d", i))))809+ appendHistoryRow(t, fp.history, testRow(fmt.Sprintf("old-%d", i)))
810 }810 }
811 fp.screen.Write([]byte("live"))811 fp.screen.Write([]byte("live"))
812 812
@@ -877,7 +877,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
877 out := awaitFrame(t, sends, ports.MsgOutput)877 out := awaitFrame(t, sends, ports.MsgOutput)
878 msg, err := ports.UnmarshalOutput(out.Payload)878 msg, err := ports.UnmarshalOutput(out.Payload)
879 require.NoError(t, err)879 require.NoError(t, err)
880- want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"880+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nb")) + "\x07"
881 require.Equal(t, want, string(msg.Data))881 require.Equal(t, want, string(msg.Data))
882 }882 }
883 883
@@ -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/daemon_test.go +8 -0
@@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell {
432432 return cells
433433 }
434434
435+// appendHistoryRow records a row whose logical extent covers it entirely and
436+// that ends its logical line — the shape of a line scrolled off a grid it never
437+// wrapped out of. Tests that care about wrapped extents build the bound instead.
438+func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
439+ tb.Helper()
440+ require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}))
441+}
442+
435443 func tabCount(sess *session) int {
436444 sess.mu.Lock()
437445 defer sess.mu.Unlock()
@@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell {
432 return cells432 return cells
433 }433 }
434 434
435+// appendHistoryRow records a row whose logical extent covers it entirely and
436+// that ends its logical line — the shape of a line scrolled off a grid it never
437+// wrapped out of. Tests that care about wrapped extents build the bound instead.
438+func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
439+ tb.Helper()
440+ require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}))
441+}
442+
435 func tabCount(sess *session) int {443 func tabCount(sess *session) int {
436 sess.mu.Lock()444 sess.mu.Lock()
437 defer sess.mu.Unlock()445 defer sess.mu.Unlock()
M internal/usecase/daemon/input_test.go +3 -3
@@ -1296,7 +1296,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
12961296 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
12971297 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
12981298 require.NoError(t, err)
1299- require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded))
1299+ require.Equal(t, "alpha\nb", string(decoded))
13001300 require.Nil(t, ac.overlays.copyMode)
13011301
13021302 exitPaint := string(mustOutputData(t, sends))
@@ -1418,7 +1418,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
14181418
14191419 // Output evicts five lines after Press; the Document remains immutable.
14201420 for range 5 {
1421- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted")))
1421+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted"))
14221422 }
14231423
14241424 // Motion maps the next endpoint through the press-owned Document.
@@ -1953,7 +1953,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
19531953 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
19541954 pane := sess.activeTab().focusedPane()
19551955 for range 8 {
1956- require.NoError(t, pane.history.Append(testRow("history")))
1956+ appendHistoryRow(t, pane.history, testRow("history"))
19571957 }
19581958 d.enterCopyMode(sess, ac)
19591959 mustOutputData(t, sends)
@@ -1296,7 +1296,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
1296 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)1296 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
1297 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))1297 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
1298 require.NoError(t, err)1298 require.NoError(t, err)
1299- require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded))1299+ require.Equal(t, "alpha\nb", string(decoded))
1300 require.Nil(t, ac.overlays.copyMode)1300 require.Nil(t, ac.overlays.copyMode)
1301 1301
1302 exitPaint := string(mustOutputData(t, sends))1302 exitPaint := string(mustOutputData(t, sends))
@@ -1418,7 +1418,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
1418 1418
1419 // Output evicts five lines after Press; the Document remains immutable.1419 // Output evicts five lines after Press; the Document remains immutable.
1420 for range 5 {1420 for range 5 {
1421- require.NoError(t, sess.tabs[0].focusedPane().history.Append(testRow("evicted")))1421+ appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted"))
1422 }1422 }
1423 1423
1424 // Motion maps the next endpoint through the press-owned Document.1424 // Motion maps the next endpoint through the press-owned Document.
@@ -1953,7 +1953,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T)
1953 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1953 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1954 pane := sess.activeTab().focusedPane()1954 pane := sess.activeTab().focusedPane()
1955 for range 8 {1955 for range 8 {
1956- require.NoError(t, pane.history.Append(testRow("history")))1956+ appendHistoryRow(t, pane.history, testRow("history"))
1957 }1957 }
1958 d.enterCopyMode(sess, ac)1958 d.enterCopyMode(sess, ac)
1959 mustOutputData(t, sends)1959 mustOutputData(t, sends)
M internal/usecase/daemon/overlay_runtime_test.go +3 -3
@@ -199,15 +199,15 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
199199 ac := &attachedClient{}
200200 ac.initOverlays()
201201 history := newTestHistory(1)
202- require.NoError(t, history.Append(testRow("before")))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
202+ appendHistoryRow(t, history, testRow("before"))
203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)
204204
205205 ac.overlays.copyMu.Lock()
206206 ac.overlays.copyMode = scopy.NewMode(document)
207207 ac.overlays.copyDocument = document
208208 ac.overlays.copyMu.Unlock()
209209
210- require.NoError(t, history.Append(testRow("after")))
210+ appendHistoryRow(t, history, testRow("after"))
211211 renderSnap := ac.overlays.SnapshotForRender()
212212 defer renderSnap.Unlock()
213213 require.NotNil(t, renderSnap.copyMode)
@@ -199,15 +199,15 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
199 ac := &attachedClient{}199 ac := &attachedClient{}
200 ac.initOverlays()200 ac.initOverlays()
201 history := newTestHistory(1)201 history := newTestHistory(1)
202- require.NoError(t, history.Append(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)
207 ac.overlays.copyDocument = document207 ac.overlays.copyDocument = document
208 ac.overlays.copyMu.Unlock()208 ac.overlays.copyMu.Unlock()
209 209
210- require.NoError(t, history.Append(testRow("after")))210+ appendHistoryRow(t, history, testRow("after"))
211 renderSnap := ac.overlays.SnapshotForRender()211 renderSnap := ac.overlays.SnapshotForRender()
212 defer renderSnap.Unlock()212 defer renderSnap.Unlock()
213 require.NotNil(t, renderSnap.copyMode)213 require.NotNil(t, renderSnap.copyMode)
M internal/usecase/daemon/performance_benchmark_test.go +1 -3
@@ -643,9 +643,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
643643 for column := range row {
644644 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
645645 }
646- if err := fixture.activePane.history.Append(row); err != nil {
647- fixture.t.Fatal(err)
648- }
646+ appendHistoryRow(fixture.t, fixture.activePane.history, row)
649647 }
650648
651649 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
@@ -643,9 +643,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
643 for column := range row {643 for column := range row {
644 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}644 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
645 }645 }
646- if err := fixture.activePane.history.Append(row); err != nil {646+ appendHistoryRow(fixture.t, fixture.activePane.history, row)
647- fixture.t.Fatal(err)
648- }
649 }647 }
650 648
651 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {649 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
M internal/usecase/daemon/picker_test.go +1 -1
@@ -835,7 +835,7 @@ func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
835835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
836836 p := tb.focusedPane()
837837 for range historyRows {
838- require.NoError(t, p.screen.History().Append(testRow("history-only-marker")))
838+ appendHistoryRow(t, p.screen.History(), testRow("history-only-marker"))
839839 }
840840 p.screen.Write([]byte("NOW"))
841841 return tb
@@ -835,7 +835,7 @@ func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})835 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
836 p := tb.focusedPane()836 p := tb.focusedPane()
837 for range historyRows {837 for range historyRows {
838- require.NoError(t, p.screen.History().Append(testRow("history-only-marker")))838+ appendHistoryRow(t, p.screen.History(), testRow("history-only-marker"))
839 }839 }
840 p.screen.Write([]byte("NOW"))840 p.screen.Write([]byte("NOW"))
841 return tb841 return tb
M internal/usecase/daemon/render_test.go +2 -2
@@ -1264,7 +1264,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12641264 }
12651265 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12661266 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())
12681268 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
12691269 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
12701270
@@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
12891289 defer releaseFloating()
12901290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
12911291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1292- require.NoError(t, fp.history.Append(testRow("flt-old")))
1292+ appendHistoryRow(t, fp.history, testRow("flt-old"))
12931293 fp.screen.Write([]byte("flt-live"))
12941294 installTestFloating(sess.activeTab(), fp, true)
12951295
@@ -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
@@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1289 defer releaseFloating()1289 defer releaseFloating()
1290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)1290 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})1291 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1292- require.NoError(t, fp.history.Append(testRow("flt-old")))1292+ appendHistoryRow(t, fp.history, testRow("flt-old"))
1293 fp.screen.Write([]byte("flt-live"))1293 fp.screen.Write([]byte("flt-live"))
1294 installTestFloating(sess.activeTab(), fp, true)1294 installTestFloating(sess.activeTab(), fp, true)
1295 1295
M internal/usecase/daemon/snapshot_acceptance_test.go +1 -1
@@ -56,7 +56,7 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh
5656 for col := range cells {
5757 cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}
5858 }
59- require.NoError(t, history.Append(cells))
59+ appendHistoryRow(t, history, cells)
6060 }
6161 return history.SnapshotView()
6262 }
@@ -56,7 +56,7 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh
56 for col := range cells {56 for col := range cells {
57 cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}57 cells[col] = renderer.Cell{Rune: first + rune((row+col)%26)}
58 }58 }
59- require.NoError(t, history.Append(cells))59+ appendHistoryRow(t, history, cells)
60 }60 }
61 return history.SnapshotView()61 return history.SnapshotView()
62 }62 }
M internal/usecase/daemon/snapshot_incremental_test.go +3 -3
@@ -22,7 +22,7 @@ func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
2222 t.Run(tt.name, func(t *testing.T) {
2323 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})
2424 if tt.fill {
25- require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
25+ appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}})
2626 }
2727 tail := history.SnapshotView().Tail()
2828
@@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
7171 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
7272 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
7373 for i := range 6 {
74- require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}}))
74+ appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}})
7575 }
7676 view := history.SnapshotView()
7777 require.Equal(t, 6, view.ChunkCount())
@@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
119119
120120 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
121121 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
122- require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()}))
122+ appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
123123 view := history.SnapshotView()
124124 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
125125 d := New(nil, nil, nil)
@@ -22,7 +22,7 @@ func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
22 t.Run(tt.name, func(t *testing.T) {22 t.Run(tt.name, func(t *testing.T) {
23 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})23 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2})
24 if tt.fill {24 if tt.fill {
25- require.NoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))25+ appendHistoryRow(t, history, []renderer.Cell{{Rune: 'x'}})
26 }26 }
27 tail := history.SnapshotView().Tail()27 tail := history.SnapshotView().Tail()
28 28
@@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
71 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {71 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
72 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})72 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
73 for i := range 6 {73 for i := range 6 {
74- require.NoError(t, history.Append([]renderer.Cell{{Rune: rune('a' + i)}}))74+ appendHistoryRow(t, history, []renderer.Cell{{Rune: rune('a' + i)}})
75 }75 }
76 view := history.SnapshotView()76 view := history.SnapshotView()
77 require.Equal(t, 6, view.ChunkCount())77 require.Equal(t, 6, view.ChunkCount())
@@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
119 119
120 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {120 func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
121 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})121 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
122- require.NoError(t, history.Append([]renderer.Cell{renderer.BlankCell()}))122+ appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
123 view := history.SnapshotView()123 view := history.SnapshotView()
124 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()124 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
125 d := New(nil, nil, nil)125 d := New(nil, nil, nil)
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
@@ -132,7 +132,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
132132 for i := range row {
133133 row[i] = renderer.Cell{Rune: ch}
134134 }
135- require.NoError(t, history.Append(row))
135+ appendHistoryRow(t, history, row)
136136 }
137137 view := history.SnapshotView()
138138 sealed := make([][]byte, view.ChunkCount())
@@ -132,7 +132,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
132 for i := range row {132 for i := range row {
133 row[i] = renderer.Cell{Rune: ch}133 row[i] = renderer.Cell{Rune: ch}
134 }134 }
135- require.NoError(t, history.Append(row))135+ appendHistoryRow(t, history, row)
136 }136 }
137 view := history.SnapshotView()137 view := history.SnapshotView()
138 sealed := make([][]byte, view.ChunkCount())138 sealed := make([][]byte, view.ChunkCount())
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -44,7 +44,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
4444 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
4545 p := tb.panes["pane-1"]
4646 p.screen.Write([]byte("hello"))
47- require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))
47+ appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
4848 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
4949 sess.snapEligible.Store(!ephemeral && name != "")
5050 return sess
@@ -44,7 +44,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
44 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})44 tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
45 p := tb.panes["pane-1"]45 p := tb.panes["pane-1"]
46 p.screen.Write([]byte("hello"))46 p.screen.Write([]byte("hello"))
47- require.NoError(t, p.history.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}))47+ appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
48 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}48 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
49 sess.snapEligible.Store(!ephemeral && name != "")49 sess.snapEligible.Store(!ephemeral && name != "")
50 return sess50 return sess
M internal/usecase/snapshot/codec_test.go +3 -3
@@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
210210 // must remain a valid v3 import payload.
211211 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
212212 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
213- requireNoError(t, mutableHistory.Append(row))
213+ requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))
214214 }
215215 mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())
216216 requireNoError(t, err)
@@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
343343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
344344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
345345 for range 256 {
346- requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))
346+ requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}, vt.LineBound{End: 1}))
347347 }
348348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
349349 requireNoError(t, err)
@@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
406406 t.Helper()
407407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
408408 for _, row := range rows {
409- requireNoError(t, h.Append(row))
409+ requireNoError(t, h.Append(row, vt.LineBound{End: len(row)}))
410410 }
411411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
412412 if err != nil {
@@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
210 // must remain a valid v3 import payload.210 // must remain a valid v3 import payload.
211 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})211 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
212 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {212 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
213- requireNoError(t, mutableHistory.Append(row))213+ requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))
214 }214 }
215 mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())215 mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView())
216 requireNoError(t, err)216 requireNoError(t, err)
@@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {343 func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})344 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256})
345 for range 256 {345 for range 256 {
346- requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}))346+ requireNoError(t, history.Append([]renderer.Cell{{Rune: 'x'}}, vt.LineBound{End: 1}))
347 }347 }
348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())348 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
349 requireNoError(t, err)349 requireNoError(t, err)
@@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
406 t.Helper()406 t.Helper()
407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})407 h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2})
408 for _, row := range rows {408 for _, row := range rows {
409- requireNoError(t, h.Append(row))409+ requireNoError(t, h.Append(row, vt.LineBound{End: len(row)}))
410 }410 }
411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())411 sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
412 if err != nil {412 if err != nil {
M pkg/vt/benchmark_test.go +6 -1
@@ -28,9 +28,14 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
2828 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
2929 partialBefore := partial.View()
3030
31+ // Copying a partial tail costs one allocation per row plus a fixed set of
32+ // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
33+ // the row slice, and the bounds slice parallel to it.
34+ const tailHeaders = 4
35+
3136 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
3237 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
33- if partialAllocs > sealedAllocs+tailRows+3 {
38+ if partialAllocs > sealedAllocs+tailRows+tailHeaders {
3439 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
3540 }
3641 if partial.View().Chunk(0) != partialBefore.Chunk(0) {
@@ -28,9 +28,14 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
28 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)28 partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
29 partialBefore := partial.View()29 partialBefore := partial.View()
30 30
31+ // Copying a partial tail costs one allocation per row plus a fixed set of
32+ // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
33+ // the row slice, and the bounds slice parallel to it.
34+ const tailHeaders = 4
35+
31 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })36 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
32 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })37 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
33- if partialAllocs > sealedAllocs+tailRows+3 {38+ if partialAllocs > sealedAllocs+tailRows+tailHeaders {
34 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)39 t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
35 }40 }
36 if partial.View().Chunk(0) != partialBefore.Chunk(0) {41 if partial.View().Chunk(0) != partialBefore.Chunk(0) {
M pkg/vt/buffer.go +65 -40
@@ -7,26 +7,28 @@ import "github.com/bnema/vev/pkg/renderer"
77 // reflow is bounded by the live grid.
88 type buffer struct {
99 frame renderer.Frame
10- boundaries []lineBoundary
10+ boundaries []LineBound
1111 }
1212
13-type lineBoundary struct {
14- // end is the meaningful cell extent. It excludes padding introduced when a
15- // wide rune was moved off the right edge.
16- end int
17- soft bool // the row continues into the following physical row
13+// LineBound describes a physical row's logical extent. End is exclusive: it is
14+// the count of meaningful cells, so the last significant column is End-1. It
15+// excludes padding introduced when a wide rune was moved off the right edge.
16+// Soft reports that the row continues into the following physical row.
17+type LineBound struct {
18+ End int
19+ Soft bool
1820 }
1921
2022 func newBuffer(width, height int) *buffer {
21- return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]lineBoundary, height)}
23+ return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)}
2224 }
2325
2426 func bufferFromFrame(frame renderer.Frame) *buffer {
25- return &buffer{frame: frame, boundaries: make([]lineBoundary, frame.Height)}
27+ return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)}
2628 }
2729
2830 func (b *buffer) clone() *buffer {
29- out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]lineBoundary(nil), b.boundaries...)}
31+ out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)}
3032 return out
3133 }
3234
@@ -34,40 +36,50 @@ func (b *buffer) content(y, end int) {
3436 if y < 0 || y >= len(b.boundaries) {
3537 return
3638 }
37- b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
39+ b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
40+}
41+
42+// bound reports the logical extent of row y. Callers on eviction paths may run
43+// against a nil or shorter buffer than the frame they are walking, so an
44+// unknown row reads as a hard row of no extent rather than panicking.
45+func (b *buffer) bound(y int) LineBound {
46+ if b == nil || y < 0 || y >= len(b.boundaries) {
47+ return LineBound{}
48+ }
49+ return b.boundaries[y]
3850 }
3951
4052 func (b *buffer) truncate(y, end int) {
4153 if y < 0 || y >= len(b.boundaries) {
4254 return
4355 }
44- b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
56+ b.boundaries[y].End = min(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
4557 }
4658
4759 // insert retains the meaningful shifted tail when insertion happens within it.
4860 func (b *buffer) insert(y, at, width int) {
49- if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end {
61+ if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].End {
5062 return
5163 }
52- b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width)
64+ b.boundaries[y].End = min(b.boundaries[y].End+width, b.frame.Width)
5365 }
5466
5567 func (b *buffer) hard(y int) {
5668 if y >= 0 && y < len(b.boundaries) {
57- b.boundaries[y].soft = false
69+ b.boundaries[y].Soft = false
5870 }
5971 }
6072
6173 func (b *buffer) soft(y int) {
6274 if y >= 0 && y < len(b.boundaries) {
63- b.boundaries[y].end = b.frame.Width
64- b.boundaries[y].soft = true
75+ b.boundaries[y].End = b.frame.Width
76+ b.boundaries[y].Soft = true
6577 }
6678 }
6779
6880 func (b *buffer) continueRow(y int) {
6981 if y >= 0 && y < len(b.boundaries) {
70- b.boundaries[y].soft = true
82+ b.boundaries[y].Soft = true
7183 }
7284 }
7385
@@ -80,10 +92,10 @@ func (b *buffer) clear(y, x0, x1 int) {
8092 // Erasing through the right edge leaves nothing flowing onto the next
8193 // row: the logical line ends here. Keeping a stale soft link would let
8294 // reflow merge a repainted row with the unrelated row below it.
83- b.boundaries[y].soft = false
95+ b.boundaries[y].Soft = false
8496 }
8597 if x0 == 0 && x1 >= b.frame.Width {
86- b.boundaries[y] = lineBoundary{}
98+ b.boundaries[y] = LineBound{}
8799 }
88100 }
89101
@@ -95,7 +107,7 @@ func (b *buffer) scrollUp(top, bottom, n int) {
95107 b.hard(top - 1)
96108 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
97109 for y := bottom - n + 1; y <= bottom; y++ {
98- b.boundaries[y] = lineBoundary{}
110+ b.boundaries[y] = LineBound{}
99111 }
100112 b.hard(bottom - n)
101113 }
@@ -106,19 +118,19 @@ func (b *buffer) scrollDown(top, bottom, n int) {
106118 b.hard(top - 1)
107119 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
108120 for y := top; y < top+n; y++ {
109- b.boundaries[y] = lineBoundary{}
121+ b.boundaries[y] = LineBound{}
110122 }
111123 b.hard(bottom)
112124 }
113125
114126 func (b *buffer) hydrate() {
115127 for y := range b.boundaries {
116- if b.boundaries[y].end != 0 {
128+ if b.boundaries[y].End != 0 {
117129 continue
118130 }
119131 for x := b.frame.Width - 1; x >= 0; x-- {
120132 if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
121- b.boundaries[y].end = x + 1
133+ b.boundaries[y].End = x + 1
122134 break
123135 }
124136 }
@@ -129,7 +141,7 @@ type bufferCursor struct{ row, col int }
129141
130142 func (b *buffer) hasSoft() bool {
131143 for _, boundary := range b.boundaries {
132- if boundary.soft {
144+ if boundary.Soft {
133145 return true
134146 }
135147 }
@@ -138,22 +150,25 @@ func (b *buffer) hasSoft() bool {
138150
139151 // resizeFixed keeps hard physical lines independent. It is the common shell
140152 // path and avoids constructing logical-line scratch state when nothing wraps.
141-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
153+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
142154 anchor := 0
143155 if active != nil {
144156 anchor = active.row
145157 }
146158 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
147159 evicted := make([][]renderer.Cell, 0, shift)
160+ evictedBounds := make([]LineBound, 0, shift)
148161 for y := range shift {
149162 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163+ // The evicted row keeps its source width, so its extent needs no clamping.
164+ evictedBounds = append(evictedBounds, b.boundaries[y])
150165 }
151166 // Fixed-line resizes can retain the boundary backing store. In particular,
152167 // keep its capacity through a short viewport so the common shrink/grow
153168 // sequence does not add metadata allocation to every resize epoch.
154169 boundaries := b.boundaries
155170 if cap(boundaries) < height {
156- boundaries = make([]lineBoundary, height)
171+ boundaries = make([]LineBound, height)
157172 } else {
158173 boundaries = boundaries[:height]
159174 }
@@ -166,7 +181,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][
166181 }
167182 copy(next.frame.Row(y), b.frame.Row(sy))
168183 next.boundaries[y] = b.boundaries[sy]
169- next.boundaries[y].end = min(next.boundaries[y].end, width)
184+ next.boundaries[y].End = min(next.boundaries[y].End, width)
170185 repairFrameRow(next.frame, y)
171186 copied++
172187 }
@@ -178,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][
178193 }
179194 }
180195 *b = next
181- return evicted
196+ return evicted, evictedBounds
182197 }
183198
184199 type reflowPoint struct {
@@ -194,7 +209,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
194209 cursors := [2]*bufferCursor{active, saved}
195210 for start := 0; start < b.frame.Height; {
196211 end := start
197- for b.boundaries[end].soft && end+1 < b.frame.Height {
212+ for b.boundaries[end].Soft && end+1 < b.frame.Height {
198213 end++
199214 }
200215 for i, cur := range cursors {
@@ -203,11 +218,11 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
203218 }
204219 offset := 0
205220 for y := start; y < cur.row; y++ {
206- offset += b.boundaries[y].end
221+ offset += b.boundaries[y].End
207222 }
208223 points[i] = reflowPoint{
209224 line: start,
210- offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end),
225+ offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].End),
211226 }
212227 }
213228 start = end + 1
@@ -219,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
219234 // the first maps both cursors and counts rows; the second writes only the
220235 // retained viewport (and the rows genuinely evicted to history). It never
221236 // materializes logical lines, per-cell position maps, or temporary output rows.
222-func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
237+func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
223238 if !b.hasSoft() {
224239 return b.resizeFixed(width, height, active, saved)
225240 }
@@ -233,7 +248,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
233248 }
234249
235250 points := b.cursorReflowPoints(active, saved)
236- rows := b.layoutReflow(width, &points, nil, 0, nil)
251+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
237252 anchor := 0
238253 if active != nil {
239254 anchor = points[0].row
@@ -243,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
243258 next := newBuffer(width, height)
244259 var evicted [][]renderer.Cell
245260 var evictedCells []renderer.Cell
261+ var evictedBounds []LineBound
246262 if shift > 0 {
247263 // History needs owned rows. One flat backing store replaces a temporary
248264 // allocation per evicted output row.
249265 evicted = make([][]renderer.Cell, shift)
250266 evictedCells = make([]renderer.Cell, shift*width)
267+ evictedBounds = make([]LineBound, shift)
251268 for y := range evicted {
252269 evicted[y] = evictedCells[y*width : (y+1)*width]
253270 }
254271 }
255- b.layoutReflow(width, &points, next, shift, evictedCells)
272+ b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
256273 for i, cur := range [2]*bufferCursor{active, saved} {
257274 if cur != nil {
258275 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -260,13 +277,13 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
260277 }
261278 }
262279 *b = *next
263- return evicted
280+ return evicted, evictedBounds
264281 }
265282
266283 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
267284 // [shift, shift+dst.height). Rows before shift are written straight into the
268285 // contiguous eviction backing store. The return is the logical output height.
269-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int {
286+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
270287 row, col := 0, 0
271288 blankEvicted := func(outputRow int) []renderer.Cell {
272289 if outputRow < shift {
@@ -286,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
286303 output = blankEvicted(0)
287304 }
288305 finishRow := func(soft bool) {
289- if dst != nil && row >= shift && row < shift+dst.frame.Height {
290- dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft}
306+ // An output row belongs either to history or to the retained viewport, and
307+ // only this pass knows where it ended, so both destinations are written here.
308+ bound := LineBound{End: col, Soft: soft}
309+ switch {
310+ case row < shift:
311+ if evictedBounds != nil {
312+ evictedBounds[row] = bound
313+ }
314+ case dst != nil && row < shift+dst.frame.Height:
315+ dst.boundaries[row-shift] = bound
291316 }
292317 row++
293318 col = 0
@@ -312,7 +337,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
312337
313338 for start := 0; start < b.frame.Height; {
314339 end := start
315- for b.boundaries[end].soft && end+1 < b.frame.Height {
340+ for b.boundaries[end].Soft && end+1 < b.frame.Height {
316341 end++
317342 }
318343 reflow := end > start
@@ -320,7 +345,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
320345 truncated := false
321346 for y := start; y <= end && !truncated; y++ {
322347 cells := b.frame.Row(y)
323- limit := b.boundaries[y].end
348+ limit := b.boundaries[y].End
324349 for x := 0; x < limit; {
325350 cell := cells[x]
326351 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
@@ -7,26 +7,28 @@ import "github.com/bnema/vev/pkg/renderer"
7 // reflow is bounded by the live grid.7 // reflow is bounded by the live grid.
8 type buffer struct {8 type buffer struct {
9 frame renderer.Frame9 frame renderer.Frame
10- boundaries []lineBoundary10+ boundaries []LineBound
11 }11 }
12 12
13-type lineBoundary struct {13+// LineBound describes a physical row's logical extent. End is exclusive: it is
14- // end is the meaningful cell extent. It excludes padding introduced when a14+// the count of meaningful cells, so the last significant column is End-1. It
15- // wide rune was moved off the right edge.15+// excludes padding introduced when a wide rune was moved off the right edge.
16- end int16+// Soft reports that the row continues into the following physical row.
17- soft bool // the row continues into the following physical row17+type LineBound struct {
18+ End int
19+ Soft bool
18 }20 }
19 21
20 func newBuffer(width, height int) *buffer {22 func newBuffer(width, height int) *buffer {
21- return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]lineBoundary, height)}23+ return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)}
22 }24 }
23 25
24 func bufferFromFrame(frame renderer.Frame) *buffer {26 func bufferFromFrame(frame renderer.Frame) *buffer {
25- return &buffer{frame: frame, boundaries: make([]lineBoundary, frame.Height)}27+ return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)}
26 }28 }
27 29
28 func (b *buffer) clone() *buffer {30 func (b *buffer) clone() *buffer {
29- out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]lineBoundary(nil), b.boundaries...)}31+ out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)}
30 return out32 return out
31 }33 }
32 34
@@ -34,40 +36,50 @@ func (b *buffer) content(y, end int) {
34 if y < 0 || y >= len(b.boundaries) {36 if y < 0 || y >= len(b.boundaries) {
35 return37 return
36 }38 }
37- b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))39+ b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
40+}
41+
42+// bound reports the logical extent of row y. Callers on eviction paths may run
43+// against a nil or shorter buffer than the frame they are walking, so an
44+// unknown row reads as a hard row of no extent rather than panicking.
45+func (b *buffer) bound(y int) LineBound {
46+ if b == nil || y < 0 || y >= len(b.boundaries) {
47+ return LineBound{}
48+ }
49+ return b.boundaries[y]
38 }50 }
39 51
40 func (b *buffer) truncate(y, end int) {52 func (b *buffer) truncate(y, end int) {
41 if y < 0 || y >= len(b.boundaries) {53 if y < 0 || y >= len(b.boundaries) {
42 return54 return
43 }55 }
44- b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width))56+ b.boundaries[y].End = min(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
45 }57 }
46 58
47 // insert retains the meaningful shifted tail when insertion happens within it.59 // insert retains the meaningful shifted tail when insertion happens within it.
48 func (b *buffer) insert(y, at, width int) {60 func (b *buffer) insert(y, at, width int) {
49- if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end {61+ if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].End {
50 return62 return
51 }63 }
52- b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width)64+ b.boundaries[y].End = min(b.boundaries[y].End+width, b.frame.Width)
53 }65 }
54 66
55 func (b *buffer) hard(y int) {67 func (b *buffer) hard(y int) {
56 if y >= 0 && y < len(b.boundaries) {68 if y >= 0 && y < len(b.boundaries) {
57- b.boundaries[y].soft = false69+ b.boundaries[y].Soft = false
58 }70 }
59 }71 }
60 72
61 func (b *buffer) soft(y int) {73 func (b *buffer) soft(y int) {
62 if y >= 0 && y < len(b.boundaries) {74 if y >= 0 && y < len(b.boundaries) {
63- b.boundaries[y].end = b.frame.Width75+ b.boundaries[y].End = b.frame.Width
64- b.boundaries[y].soft = true76+ b.boundaries[y].Soft = true
65 }77 }
66 }78 }
67 79
68 func (b *buffer) continueRow(y int) {80 func (b *buffer) continueRow(y int) {
69 if y >= 0 && y < len(b.boundaries) {81 if y >= 0 && y < len(b.boundaries) {
70- b.boundaries[y].soft = true82+ b.boundaries[y].Soft = true
71 }83 }
72 }84 }
73 85
@@ -80,10 +92,10 @@ func (b *buffer) clear(y, x0, x1 int) {
80 // Erasing through the right edge leaves nothing flowing onto the next92 // Erasing through the right edge leaves nothing flowing onto the next
81 // row: the logical line ends here. Keeping a stale soft link would let93 // row: the logical line ends here. Keeping a stale soft link would let
82 // reflow merge a repainted row with the unrelated row below it.94 // reflow merge a repainted row with the unrelated row below it.
83- b.boundaries[y].soft = false95+ b.boundaries[y].Soft = false
84 }96 }
85 if x0 == 0 && x1 >= b.frame.Width {97 if x0 == 0 && x1 >= b.frame.Width {
86- b.boundaries[y] = lineBoundary{}98+ b.boundaries[y] = LineBound{}
87 }99 }
88 }100 }
89 101
@@ -95,7 +107,7 @@ func (b *buffer) scrollUp(top, bottom, n int) {
95 b.hard(top - 1)107 b.hard(top - 1)
96 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])108 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
97 for y := bottom - n + 1; y <= bottom; y++ {109 for y := bottom - n + 1; y <= bottom; y++ {
98- b.boundaries[y] = lineBoundary{}110+ b.boundaries[y] = LineBound{}
99 }111 }
100 b.hard(bottom - n)112 b.hard(bottom - n)
101 }113 }
@@ -106,19 +118,19 @@ func (b *buffer) scrollDown(top, bottom, n int) {
106 b.hard(top - 1)118 b.hard(top - 1)
107 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])119 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
108 for y := top; y < top+n; y++ {120 for y := top; y < top+n; y++ {
109- b.boundaries[y] = lineBoundary{}121+ b.boundaries[y] = LineBound{}
110 }122 }
111 b.hard(bottom)123 b.hard(bottom)
112 }124 }
113 125
114 func (b *buffer) hydrate() {126 func (b *buffer) hydrate() {
115 for y := range b.boundaries {127 for y := range b.boundaries {
116- if b.boundaries[y].end != 0 {128+ if b.boundaries[y].End != 0 {
117 continue129 continue
118 }130 }
119 for x := b.frame.Width - 1; x >= 0; x-- {131 for x := b.frame.Width - 1; x >= 0; x-- {
120 if !b.frame.At(x, y).Equal(renderer.BlankCell()) {132 if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
121- b.boundaries[y].end = x + 1133+ b.boundaries[y].End = x + 1
122 break134 break
123 }135 }
124 }136 }
@@ -129,7 +141,7 @@ type bufferCursor struct{ row, col int }
129 141
130 func (b *buffer) hasSoft() bool {142 func (b *buffer) hasSoft() bool {
131 for _, boundary := range b.boundaries {143 for _, boundary := range b.boundaries {
132- if boundary.soft {144+ if boundary.Soft {
133 return true145 return true
134 }146 }
135 }147 }
@@ -138,22 +150,25 @@ func (b *buffer) hasSoft() bool {
138 150
139 // resizeFixed keeps hard physical lines independent. It is the common shell151 // resizeFixed keeps hard physical lines independent. It is the common shell
140 // path and avoids constructing logical-line scratch state when nothing wraps.152 // path and avoids constructing logical-line scratch state when nothing wraps.
141-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell {153+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
142 anchor := 0154 anchor := 0
143 if active != nil {155 if active != nil {
144 anchor = active.row156 anchor = active.row
145 }157 }
146 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))158 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
147 evicted := make([][]renderer.Cell, 0, shift)159 evicted := make([][]renderer.Cell, 0, shift)
160+ evictedBounds := make([]LineBound, 0, shift)
148 for y := range shift {161 for y := range shift {
149 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))162 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163+ // The evicted row keeps its source width, so its extent needs no clamping.
164+ evictedBounds = append(evictedBounds, b.boundaries[y])
150 }165 }
151 // Fixed-line resizes can retain the boundary backing store. In particular,166 // Fixed-line resizes can retain the boundary backing store. In particular,
152 // keep its capacity through a short viewport so the common shrink/grow167 // keep its capacity through a short viewport so the common shrink/grow
153 // sequence does not add metadata allocation to every resize epoch.168 // sequence does not add metadata allocation to every resize epoch.
154 boundaries := b.boundaries169 boundaries := b.boundaries
155 if cap(boundaries) < height {170 if cap(boundaries) < height {
156- boundaries = make([]lineBoundary, height)171+ boundaries = make([]LineBound, height)
157 } else {172 } else {
158 boundaries = boundaries[:height]173 boundaries = boundaries[:height]
159 }174 }
@@ -166,7 +181,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][
166 }181 }
167 copy(next.frame.Row(y), b.frame.Row(sy))182 copy(next.frame.Row(y), b.frame.Row(sy))
168 next.boundaries[y] = b.boundaries[sy]183 next.boundaries[y] = b.boundaries[sy]
169- next.boundaries[y].end = min(next.boundaries[y].end, width)184+ next.boundaries[y].End = min(next.boundaries[y].End, width)
170 repairFrameRow(next.frame, y)185 repairFrameRow(next.frame, y)
171 copied++186 copied++
172 }187 }
@@ -178,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][
178 }193 }
179 }194 }
180 *b = next195 *b = next
181- return evicted196+ return evicted, evictedBounds
182 }197 }
183 198
184 type reflowPoint struct {199 type reflowPoint struct {
@@ -194,7 +209,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
194 cursors := [2]*bufferCursor{active, saved}209 cursors := [2]*bufferCursor{active, saved}
195 for start := 0; start < b.frame.Height; {210 for start := 0; start < b.frame.Height; {
196 end := start211 end := start
197- for b.boundaries[end].soft && end+1 < b.frame.Height {212+ for b.boundaries[end].Soft && end+1 < b.frame.Height {
198 end++213 end++
199 }214 }
200 for i, cur := range cursors {215 for i, cur := range cursors {
@@ -203,11 +218,11 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
203 }218 }
204 offset := 0219 offset := 0
205 for y := start; y < cur.row; y++ {220 for y := start; y < cur.row; y++ {
206- offset += b.boundaries[y].end221+ offset += b.boundaries[y].End
207 }222 }
208 points[i] = reflowPoint{223 points[i] = reflowPoint{
209 line: start,224 line: start,
210- offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end),225+ offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].End),
211 }226 }
212 }227 }
213 start = end + 1228 start = end + 1
@@ -219,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
219 // the first maps both cursors and counts rows; the second writes only the234 // the first maps both cursors and counts rows; the second writes only the
220 // retained viewport (and the rows genuinely evicted to history). It never235 // retained viewport (and the rows genuinely evicted to history). It never
221 // materializes logical lines, per-cell position maps, or temporary output rows.236 // materializes logical lines, per-cell position maps, or temporary output rows.
222-func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell {237+func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
223 if !b.hasSoft() {238 if !b.hasSoft() {
224 return b.resizeFixed(width, height, active, saved)239 return b.resizeFixed(width, height, active, saved)
225 }240 }
@@ -233,7 +248,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
233 }248 }
234 249
235 points := b.cursorReflowPoints(active, saved)250 points := b.cursorReflowPoints(active, saved)
236- rows := b.layoutReflow(width, &points, nil, 0, nil)251+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
237 anchor := 0252 anchor := 0
238 if active != nil {253 if active != nil {
239 anchor = points[0].row254 anchor = points[0].row
@@ -243,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
243 next := newBuffer(width, height)258 next := newBuffer(width, height)
244 var evicted [][]renderer.Cell259 var evicted [][]renderer.Cell
245 var evictedCells []renderer.Cell260 var evictedCells []renderer.Cell
261+ var evictedBounds []LineBound
246 if shift > 0 {262 if shift > 0 {
247 // History needs owned rows. One flat backing store replaces a temporary263 // History needs owned rows. One flat backing store replaces a temporary
248 // allocation per evicted output row.264 // allocation per evicted output row.
249 evicted = make([][]renderer.Cell, shift)265 evicted = make([][]renderer.Cell, shift)
250 evictedCells = make([]renderer.Cell, shift*width)266 evictedCells = make([]renderer.Cell, shift*width)
267+ evictedBounds = make([]LineBound, shift)
251 for y := range evicted {268 for y := range evicted {
252 evicted[y] = evictedCells[y*width : (y+1)*width]269 evicted[y] = evictedCells[y*width : (y+1)*width]
253 }270 }
254 }271 }
255- b.layoutReflow(width, &points, next, shift, evictedCells)272+ b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
256 for i, cur := range [2]*bufferCursor{active, saved} {273 for i, cur := range [2]*bufferCursor{active, saved} {
257 if cur != nil {274 if cur != nil {
258 cur.row = clamp(points[i].row-shift, 0, height-1)275 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -260,13 +277,13 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend
260 }277 }
261 }278 }
262 *b = *next279 *b = *next
263- return evicted280+ return evicted, evictedBounds
264 }281 }
265 282
266 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in283 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
267 // [shift, shift+dst.height). Rows before shift are written straight into the284 // [shift, shift+dst.height). Rows before shift are written straight into the
268 // contiguous eviction backing store. The return is the logical output height.285 // contiguous eviction backing store. The return is the logical output height.
269-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int {286+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
270 row, col := 0, 0287 row, col := 0, 0
271 blankEvicted := func(outputRow int) []renderer.Cell {288 blankEvicted := func(outputRow int) []renderer.Cell {
272 if outputRow < shift {289 if outputRow < shift {
@@ -286,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
286 output = blankEvicted(0)303 output = blankEvicted(0)
287 }304 }
288 finishRow := func(soft bool) {305 finishRow := func(soft bool) {
289- if dst != nil && row >= shift && row < shift+dst.frame.Height {306+ // An output row belongs either to history or to the retained viewport, and
290- dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft}307+ // only this pass knows where it ended, so both destinations are written here.
308+ bound := LineBound{End: col, Soft: soft}
309+ switch {
310+ case row < shift:
311+ if evictedBounds != nil {
312+ evictedBounds[row] = bound
313+ }
314+ case dst != nil && row < shift+dst.frame.Height:
315+ dst.boundaries[row-shift] = bound
291 }316 }
292 row++317 row++
293 col = 0318 col = 0
@@ -312,7 +337,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
312 337
313 for start := 0; start < b.frame.Height; {338 for start := 0; start < b.frame.Height; {
314 end := start339 end := start
315- for b.boundaries[end].soft && end+1 < b.frame.Height {340+ for b.boundaries[end].Soft && end+1 < b.frame.Height {
316 end++341 end++
317 }342 }
318 reflow := end > start343 reflow := end > start
@@ -320,7 +345,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
320 truncated := false345 truncated := false
321 for y := start; y <= end && !truncated; y++ {346 for y := start; y <= end && !truncated; y++ {
322 cells := b.frame.Row(y)347 cells := b.frame.Row(y)
323- limit := b.boundaries[y].end348+ limit := b.boundaries[y].End
324 for x := 0; x < limit; {349 for x := 0; x < limit; {
325 cell := cells[x]350 cell := cells[x]
326 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.351 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
M pkg/vt/history.go +80 -24
@@ -22,19 +22,22 @@ type HistoryConfig struct {
2222 // mutated by the owner of a Screen; views are safe to retain after later
2323 // appends.
2424 type History struct {
25- maxRows int
26- maxCells int
27- chunkRows int
28- chunks []*HistoryChunk
29- tail [][]renderer.Cell
30- rows int
31- cells int
25+ maxRows int
26+ maxCells int
27+ chunkRows int
28+ chunks []*HistoryChunk
29+ tail [][]renderer.Cell
30+ tailBounds []LineBound
31+ rows int
32+ cells int
3233 }
3334
3435 // HistoryChunk is an immutable group of history rows. Its identity is stable
3536 // and can be used by consumers to avoid copying unchanged sealed chunks.
37+// bounds is parallel to rows and always has the same length.
3638 type HistoryChunk struct {
37- rows [][]renderer.Cell
39+ rows [][]renderer.Cell
40+ bounds []LineBound
3841 }
3942
4043 // HistoryView is an immutable snapshot of history. Row returns a copy so the
@@ -49,10 +52,11 @@ type HistoryView struct {
4952 // independently. Sealed chunks are shared by identity; Tail is owned by the
5053 // view and can be serialized without rotating the live tail into a chunk.
5154 type HistorySnapshotView struct {
52- chunks []*HistoryChunk
53- tail [][]renderer.Cell
54- rows int
55- cells int
55+ chunks []*HistoryChunk
56+ tail [][]renderer.Cell
57+ tailBounds []LineBound
58+ rows int
59+ cells int
5660 }
5761
5862 func NewHistory(config HistoryConfig) *History {
@@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int {
7983 return maxRows * 160
8084 }
8185
82-// Append records a copy of row. Once a chunk is full it is sealed forever.
83-// Rows wider than the total cell capacity are rejected without mutation.
84-func (h *History) Append(row []renderer.Cell) error {
86+// Append records a copy of row along with its logical extent. Once a chunk is
87+// full it is sealed forever. Rows wider than the total cell capacity are
88+// rejected without mutation.
89+func (h *History) Append(row []renderer.Cell, bound LineBound) error {
8590 if h == nil || h.maxRows == 0 {
8691 return nil
8792 }
@@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error {
9297 // capacity is MaxInt.
9398 h.evictFor(len(row))
9499 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100+ h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
95101 h.rows++
96102 h.cells += len(row)
97103 if len(h.tail) == h.chunkRows {
@@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error {
100106 return nil
101107 }
102108
109+// clampBound keeps End inside the row it describes so a persisted bound can be
110+// validated without consulting its row again.
111+func clampBound(b LineBound, cells int) LineBound {
112+ b.End = min(max(b.End, 0), cells)
113+ return b
114+}
115+
116+// growBounds returns bounds sized to rows, padding with zero values. It keeps
117+// the parallel-slice invariant when a chunk arrives with fewer bounds than rows.
118+func growBounds(bounds []LineBound, rows int) []LineBound {
119+ if len(bounds) == rows {
120+ return bounds
121+ }
122+ out := make([]LineBound, rows)
123+ copy(out, bounds)
124+ return out
125+}
126+
103127 func (h *History) sealTail() {
104128 if len(h.tail) == 0 {
105129 return
106130 }
107- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})
131+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})
108132 h.tail = nil
133+ h.tailBounds = nil
109134 }
110135
111136 // normalizeTail seals complete chunks so the mutable tail remains shorter than
@@ -113,8 +138,12 @@ func (h *History) sealTail() {
113138 // size than the current history configuration.
114139 func (h *History) normalizeTail() {
115140 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
116- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]})
141+ h.chunks = append(h.chunks, &HistoryChunk{
142+ rows: h.tail[:h.chunkRows],
143+ bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],
144+ })
117145 h.tail = h.tail[h.chunkRows:]
146+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]
118147 }
119148 }
120149
@@ -143,7 +172,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
143172 } else {
144173 // Preserve cell storage while replacing only the chunk wrapper: a
145174 // retained view may still refer to the original immutable chunk.
146- h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}
175+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]}
147176 }
148177 continue
149178 }
@@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
155184 h.cells -= len(row)
156185 h.tail[0] = nil
157186 h.tail = h.tail[1:]
187+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
158188 }
159189 }
160190
@@ -175,7 +205,10 @@ func (h *History) View() HistoryView {
175205 }
176206 chunks := append([]*HistoryChunk(nil), h.chunks...)
177207 if len(h.tail) > 0 {
178- chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})
208+ chunks = append(chunks, &HistoryChunk{
209+ rows: cloneHistoryRows(h.tail),
210+ bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
211+ })
179212 }
180213 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
181214 }
@@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView {
187220 return HistorySnapshotView{}
188221 }
189222 return HistorySnapshotView{
190- chunks: append([]*HistoryChunk(nil), h.chunks...),
191- tail: cloneHistoryRows(h.tail),
192- rows: h.rows,
193- cells: h.cells,
223+ chunks: append([]*HistoryChunk(nil), h.chunks...),
224+ tail: cloneHistoryRows(h.tail),
225+ tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
226+ rows: h.rows,
227+ cells: h.cells,
194228 }
195229 }
196230
@@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
282316 return nil
283317 }
284318
319+// Bound returns the logical extent of the row at i, or the zero value when i is
320+// out of range. A zero value describes a hard row whose End is not meaningful.
321+func (v HistoryView) Bound(i int) LineBound {
322+ if i < 0 {
323+ return LineBound{}
324+ }
325+ for _, chunk := range v.chunks {
326+ if i < len(chunk.rows) {
327+ if i < len(chunk.bounds) {
328+ return chunk.bounds[i]
329+ }
330+ return LineBound{}
331+ }
332+ i -= len(chunk.rows)
333+ }
334+ return LineBound{}
335+}
336+
285337 func (v HistorySnapshotView) Len() int { return v.rows }
286338 func (v HistorySnapshotView) Cells() int { return v.cells }
287339 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
@@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView {
299351 if len(v.tail) == 0 {
300352 return HistoryView{}
301353 }
302- return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}
354+ return HistoryView{
355+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},
356+ rows: len(v.tail),
357+ cells: historyRowsCells(v.tail),
358+ }
303359 }
304360
305361 func historyRowsCells(rows [][]renderer.Cell) int {
@@ -22,19 +22,22 @@ type HistoryConfig struct {
22 // mutated by the owner of a Screen; views are safe to retain after later22 // mutated by the owner of a Screen; views are safe to retain after later
23 // appends.23 // appends.
24 type History struct {24 type History struct {
25- maxRows int25+ maxRows int
26- maxCells int26+ maxCells int
27- chunkRows int27+ chunkRows int
28- chunks []*HistoryChunk28+ chunks []*HistoryChunk
29- tail [][]renderer.Cell29+ tail [][]renderer.Cell
30- rows int30+ tailBounds []LineBound
31- cells int31+ rows int
32+ cells int
32 }33 }
33 34
34 // HistoryChunk is an immutable group of history rows. Its identity is stable35 // HistoryChunk is an immutable group of history rows. Its identity is stable
35 // and can be used by consumers to avoid copying unchanged sealed chunks.36 // and can be used by consumers to avoid copying unchanged sealed chunks.
37+// bounds is parallel to rows and always has the same length.
36 type HistoryChunk struct {38 type HistoryChunk struct {
37- rows [][]renderer.Cell39+ rows [][]renderer.Cell
40+ bounds []LineBound
38 }41 }
39 42
40 // HistoryView is an immutable snapshot of history. Row returns a copy so the43 // HistoryView is an immutable snapshot of history. Row returns a copy so the
@@ -49,10 +52,11 @@ type HistoryView struct {
49 // independently. Sealed chunks are shared by identity; Tail is owned by the52 // independently. Sealed chunks are shared by identity; Tail is owned by the
50 // view and can be serialized without rotating the live tail into a chunk.53 // view and can be serialized without rotating the live tail into a chunk.
51 type HistorySnapshotView struct {54 type HistorySnapshotView struct {
52- chunks []*HistoryChunk55+ chunks []*HistoryChunk
53- tail [][]renderer.Cell56+ tail [][]renderer.Cell
54- rows int57+ tailBounds []LineBound
55- cells int58+ rows int
59+ cells int
56 }60 }
57 61
58 func NewHistory(config HistoryConfig) *History {62 func NewHistory(config HistoryConfig) *History {
@@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int {
79 return maxRows * 16083 return maxRows * 160
80 }84 }
81 85
82-// Append records a copy of row. Once a chunk is full it is sealed forever.86+// Append records a copy of row along with its logical extent. Once a chunk is
83-// Rows wider than the total cell capacity are rejected without mutation.87+// full it is sealed forever. Rows wider than the total cell capacity are
84-func (h *History) Append(row []renderer.Cell) error {88+// rejected without mutation.
89+func (h *History) Append(row []renderer.Cell, bound LineBound) error {
85 if h == nil || h.maxRows == 0 {90 if h == nil || h.maxRows == 0 {
86 return nil91 return nil
87 }92 }
@@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error {
92 // capacity is MaxInt.97 // capacity is MaxInt.
93 h.evictFor(len(row))98 h.evictFor(len(row))
94 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))99 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100+ h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
95 h.rows++101 h.rows++
96 h.cells += len(row)102 h.cells += len(row)
97 if len(h.tail) == h.chunkRows {103 if len(h.tail) == h.chunkRows {
@@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error {
100 return nil106 return nil
101 }107 }
102 108
109+// clampBound keeps End inside the row it describes so a persisted bound can be
110+// validated without consulting its row again.
111+func clampBound(b LineBound, cells int) LineBound {
112+ b.End = min(max(b.End, 0), cells)
113+ return b
114+}
115+
116+// growBounds returns bounds sized to rows, padding with zero values. It keeps
117+// the parallel-slice invariant when a chunk arrives with fewer bounds than rows.
118+func growBounds(bounds []LineBound, rows int) []LineBound {
119+ if len(bounds) == rows {
120+ return bounds
121+ }
122+ out := make([]LineBound, rows)
123+ copy(out, bounds)
124+ return out
125+}
126+
103 func (h *History) sealTail() {127 func (h *History) sealTail() {
104 if len(h.tail) == 0 {128 if len(h.tail) == 0 {
105 return129 return
106 }130 }
107- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})131+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})
108 h.tail = nil132 h.tail = nil
133+ h.tailBounds = nil
109 }134 }
110 135
111 // normalizeTail seals complete chunks so the mutable tail remains shorter than136 // normalizeTail seals complete chunks so the mutable tail remains shorter than
@@ -113,8 +138,12 @@ func (h *History) sealTail() {
113 // size than the current history configuration.138 // size than the current history configuration.
114 func (h *History) normalizeTail() {139 func (h *History) normalizeTail() {
115 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {140 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
116- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail[:h.chunkRows]})141+ h.chunks = append(h.chunks, &HistoryChunk{
142+ rows: h.tail[:h.chunkRows],
143+ bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],
144+ })
117 h.tail = h.tail[h.chunkRows:]145 h.tail = h.tail[h.chunkRows:]
146+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]
118 }147 }
119 }148 }
120 149
@@ -143,7 +172,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
143 } else {172 } else {
144 // Preserve cell storage while replacing only the chunk wrapper: a173 // Preserve cell storage while replacing only the chunk wrapper: a
145 // retained view may still refer to the original immutable chunk.174 // retained view may still refer to the original immutable chunk.
146- h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}175+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]}
147 }176 }
148 continue177 continue
149 }178 }
@@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
155 h.cells -= len(row)184 h.cells -= len(row)
156 h.tail[0] = nil185 h.tail[0] = nil
157 h.tail = h.tail[1:]186 h.tail = h.tail[1:]
187+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
158 }188 }
159 }189 }
160 190
@@ -175,7 +205,10 @@ func (h *History) View() HistoryView {
175 }205 }
176 chunks := append([]*HistoryChunk(nil), h.chunks...)206 chunks := append([]*HistoryChunk(nil), h.chunks...)
177 if len(h.tail) > 0 {207 if len(h.tail) > 0 {
178- chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})208+ chunks = append(chunks, &HistoryChunk{
209+ rows: cloneHistoryRows(h.tail),
210+ bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
211+ })
179 }212 }
180 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}213 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
181 }214 }
@@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView {
187 return HistorySnapshotView{}220 return HistorySnapshotView{}
188 }221 }
189 return HistorySnapshotView{222 return HistorySnapshotView{
190- chunks: append([]*HistoryChunk(nil), h.chunks...),223+ chunks: append([]*HistoryChunk(nil), h.chunks...),
191- tail: cloneHistoryRows(h.tail),224+ tail: cloneHistoryRows(h.tail),
192- rows: h.rows,225+ tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
193- cells: h.cells,226+ rows: h.rows,
227+ cells: h.cells,
194 }228 }
195 }229 }
196 230
@@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
282 return nil316 return nil
283 }317 }
284 318
319+// Bound returns the logical extent of the row at i, or the zero value when i is
320+// out of range. A zero value describes a hard row whose End is not meaningful.
321+func (v HistoryView) Bound(i int) LineBound {
322+ if i < 0 {
323+ return LineBound{}
324+ }
325+ for _, chunk := range v.chunks {
326+ if i < len(chunk.rows) {
327+ if i < len(chunk.bounds) {
328+ return chunk.bounds[i]
329+ }
330+ return LineBound{}
331+ }
332+ i -= len(chunk.rows)
333+ }
334+ return LineBound{}
335+}
336+
285 func (v HistorySnapshotView) Len() int { return v.rows }337 func (v HistorySnapshotView) Len() int { return v.rows }
286 func (v HistorySnapshotView) Cells() int { return v.cells }338 func (v HistorySnapshotView) Cells() int { return v.cells }
287 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }339 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
@@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView {
299 if len(v.tail) == 0 {351 if len(v.tail) == 0 {
300 return HistoryView{}352 return HistoryView{}
301 }353 }
302- return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}354+ return HistoryView{
355+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},
356+ rows: len(v.tail),
357+ cells: historyRowsCells(v.tail),
358+ }
303 }359 }
304 360
305 func historyRowsCells(rows [][]renderer.Cell) int {361 func historyRowsCells(rows [][]renderer.Cell) int {
M pkg/vt/history_codec.go +49 -4
@@ -12,7 +12,7 @@ import (
1212
1313 const (
1414 historyMagic = "VTH1"
15- historyVersion = 1
15+ historyVersion = 2
1616 historyCellBytes = 41
1717 maxHistoryChunkRows = 256
1818
@@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
6464 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
6565 }
6666 out = appendUint32(out, uint32(len(chunk.rows)))
67- for _, row := range chunk.rows {
67+ for i, row := range chunk.rows {
6868 if uint64(len(row)) > math.MaxUint32 {
6969 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
7070 }
71+ bound := chunkBound(chunk, i)
72+ if !validHistoryBound(bound, len(row)) {
73+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74+ }
7175 out = appendUint32(out, uint32(len(row)))
7276 for _, cell := range row {
7377 if !validHistoryCell(cell) {
@@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
7579 }
7680 out = appendHistoryCell(out, cell)
7781 }
82+ out = appendHistoryBound(out, bound)
7883 }
7984 }
8085 return out, nil
@@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
135140 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
136141 }
137142
143+func chunkBound(chunk *HistoryChunk, i int) LineBound {
144+ if i < 0 || i >= len(chunk.bounds) {
145+ return LineBound{}
146+ }
147+ return chunk.bounds[i]
148+}
149+
150+func appendHistoryBound(dst []byte, b LineBound) []byte {
151+ dst = binary.BigEndian.AppendUint32(dst, uint32(b.End))
152+ if b.Soft {
153+ return append(dst, 1)
154+ }
155+ return append(dst, 0)
156+}
157+
158+// validHistoryBound rejects extents that cannot describe their own row.
159+func validHistoryBound(b LineBound, cells int) bool {
160+ return b.End >= 0 && b.End <= cells
161+}
162+
138163 // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
139164 // malformed declarations, cells, truncated data, and trailing bytes.
140165 func UnmarshalHistory(data []byte) (HistoryView, error) {
@@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
181206 for range chunkCount {
182207 rowCount, ok := p.uint32()
183208 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
184- uint64(rowCount) > uint64(len(p.data))/4 ||
209+ uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) ||
185210 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
186211 return HistoryView{}, historyDecodeStats{}, false
187212 }
188213
189214 var rows [][]renderer.Cell
215+ var bounds []LineBound
190216 if populate {
191217 rows = make([][]renderer.Cell, 0, rowCount)
218+ bounds = make([]LineBound, 0, rowCount)
192219 }
193220 for range rowCount {
194221 cellCount, ok := p.uint32()
@@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
214241 row[i] = cell
215242 }
216243 }
244+ bound, ok := p.bound()
245+ if !ok || !validHistoryBound(bound, int(cellCount)) {
246+ return HistoryView{}, historyDecodeStats{}, false
247+ }
217248 if populate {
218249 rows = append(rows, row)
250+ bounds = append(bounds, bound)
219251 }
220252 }
221253 if populate {
222- chunks = append(chunks, &HistoryChunk{rows: rows})
254+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
223255 }
224256 }
225257 if len(p.data) != 0 {
@@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) {
296328 }, true
297329 }
298330
331+func (p *historyParser) bound() (LineBound, bool) {
332+ if len(p.data) < visibleBoundaryBytes {
333+ return LineBound{}, false
334+ }
335+ end := binary.BigEndian.Uint32(p.data[:4])
336+ flag := p.data[4]
337+ p.data = p.data[visibleBoundaryBytes:]
338+ if flag > 1 || uint64(end) > math.MaxInt32 {
339+ return LineBound{}, false
340+ }
341+ return LineBound{End: int(end), Soft: flag == 1}, true
342+}
343+
299344 func historyInt(raw uint64) (int, bool) {
300345 v := int64(raw)
301346 if int64(int(v)) != v {
@@ -12,7 +12,7 @@ import (
12 12
13 const (13 const (
14 historyMagic = "VTH1"14 historyMagic = "VTH1"
15- historyVersion = 115+ historyVersion = 2
16 historyCellBytes = 4116 historyCellBytes = 41
17 maxHistoryChunkRows = 25617 maxHistoryChunkRows = 256
18 18
@@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
64 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)64 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
65 }65 }
66 out = appendUint32(out, uint32(len(chunk.rows)))66 out = appendUint32(out, uint32(len(chunk.rows)))
67- for _, row := range chunk.rows {67+ for i, row := range chunk.rows {
68 if uint64(len(row)) > math.MaxUint32 {68 if uint64(len(row)) > math.MaxUint32 {
69 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)69 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
70 }70 }
71+ bound := chunkBound(chunk, i)
72+ if !validHistoryBound(bound, len(row)) {
73+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74+ }
71 out = appendUint32(out, uint32(len(row)))75 out = appendUint32(out, uint32(len(row)))
72 for _, cell := range row {76 for _, cell := range row {
73 if !validHistoryCell(cell) {77 if !validHistoryCell(cell) {
@@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
75 }79 }
76 out = appendHistoryCell(out, cell)80 out = appendHistoryCell(out, cell)
77 }81 }
82+ out = appendHistoryBound(out, bound)
78 }83 }
79 }84 }
80 return out, nil85 return out, nil
@@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
135 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)140 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
136 }141 }
137 142
143+func chunkBound(chunk *HistoryChunk, i int) LineBound {
144+ if i < 0 || i >= len(chunk.bounds) {
145+ return LineBound{}
146+ }
147+ return chunk.bounds[i]
148+}
149+
150+func appendHistoryBound(dst []byte, b LineBound) []byte {
151+ dst = binary.BigEndian.AppendUint32(dst, uint32(b.End))
152+ if b.Soft {
153+ return append(dst, 1)
154+ }
155+ return append(dst, 0)
156+}
157+
158+// validHistoryBound rejects extents that cannot describe their own row.
159+func validHistoryBound(b LineBound, cells int) bool {
160+ return b.End >= 0 && b.End <= cells
161+}
162+
138 // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects163 // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
139 // malformed declarations, cells, truncated data, and trailing bytes.164 // malformed declarations, cells, truncated data, and trailing bytes.
140 func UnmarshalHistory(data []byte) (HistoryView, error) {165 func UnmarshalHistory(data []byte) (HistoryView, error) {
@@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
181 for range chunkCount {206 for range chunkCount {
182 rowCount, ok := p.uint32()207 rowCount, ok := p.uint32()
183 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||208 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
184- uint64(rowCount) > uint64(len(p.data))/4 ||209+ uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) ||
185 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {210 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
186 return HistoryView{}, historyDecodeStats{}, false211 return HistoryView{}, historyDecodeStats{}, false
187 }212 }
188 213
189 var rows [][]renderer.Cell214 var rows [][]renderer.Cell
215+ var bounds []LineBound
190 if populate {216 if populate {
191 rows = make([][]renderer.Cell, 0, rowCount)217 rows = make([][]renderer.Cell, 0, rowCount)
218+ bounds = make([]LineBound, 0, rowCount)
192 }219 }
193 for range rowCount {220 for range rowCount {
194 cellCount, ok := p.uint32()221 cellCount, ok := p.uint32()
@@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
214 row[i] = cell241 row[i] = cell
215 }242 }
216 }243 }
244+ bound, ok := p.bound()
245+ if !ok || !validHistoryBound(bound, int(cellCount)) {
246+ return HistoryView{}, historyDecodeStats{}, false
247+ }
217 if populate {248 if populate {
218 rows = append(rows, row)249 rows = append(rows, row)
250+ bounds = append(bounds, bound)
219 }251 }
220 }252 }
221 if populate {253 if populate {
222- chunks = append(chunks, &HistoryChunk{rows: rows})254+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
223 }255 }
224 }256 }
225 if len(p.data) != 0 {257 if len(p.data) != 0 {
@@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) {
296 }, true328 }, true
297 }329 }
298 330
331+func (p *historyParser) bound() (LineBound, bool) {
332+ if len(p.data) < visibleBoundaryBytes {
333+ return LineBound{}, false
334+ }
335+ end := binary.BigEndian.Uint32(p.data[:4])
336+ flag := p.data[4]
337+ p.data = p.data[visibleBoundaryBytes:]
338+ if flag > 1 || uint64(end) > math.MaxInt32 {
339+ return LineBound{}, false
340+ }
341+ return LineBound{End: int(end), Soft: flag == 1}, true
342+}
343+
299 func historyInt(raw uint64) (int, bool) {344 func historyInt(raw uint64) (int, bool) {
300 v := int64(raw)345 v := int64(raw)
301 if int64(int(v)) != v {346 if int64(int(v)) != v {
M pkg/vt/history_codec_test.go +89 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "bytes"
45 "encoding/binary"
56 "fmt"
67 "math"
@@ -9,6 +10,94 @@ import (
910 "github.com/bnema/vev/pkg/renderer"
1011 )
1112
13+func TestHistoryCodecV2Bytes(t *testing.T) {
14+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
15+ if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
16+ t.Fatalf("append: %v", err)
17+ }
18+ got, err := MarshalHistory(h.SealAndView())
19+ if err != nil {
20+ t.Fatalf("marshal: %v", err)
21+ }
22+
23+ want := []byte{
24+ 'V', 'T', 'H', '1', 2, // magic, version
25+ 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 1, // row count
27+ 0, 0, 0, 1, // cell count
28+ 0, 0, 0, 'a', // Rune
29+ 0, // flags
30+ 0, 0, // Attrs
31+ 0, 0, 0, 0, 0, 0, 0, 0, // Foreground
32+ 0, 0, 0, 0, 0, 0, 0, 0, // Background
33+ 0, 0, 0, // ForegroundRGB
34+ 0, 0, 0, // BackgroundRGB
35+ 0, // UnderlineStyle
36+ 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
37+ 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
38+ 0, 0, 0, 1, 1, // bound: End=1, Soft=true
39+ }
40+ if !bytes.Equal(got, want) {
41+ t.Fatalf("MarshalHistory() = % x, want % x", got, want)
42+ }
43+}
44+
45+func TestHistoryCodecRoundTripsBounds(t *testing.T) {
46+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
47+ bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
48+ for _, b := range bounds {
49+ if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
50+ t.Fatalf("append: %v", err)
51+ }
52+ }
53+ blob, err := MarshalHistory(h.SealAndView())
54+ if err != nil {
55+ t.Fatalf("marshal: %v", err)
56+ }
57+ view, err := UnmarshalHistory(blob)
58+ if err != nil {
59+ t.Fatalf("unmarshal: %v", err)
60+ }
61+ for i, want := range bounds {
62+ if got := view.Bound(i); got != want {
63+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
64+ }
65+ }
66+}
67+
68+func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
69+ // Canonical v2 fixture from TestHistoryCodecV2Bytes.
70+ v2 := []byte{
71+ 'V', 'T', 'H', '1', 2,
72+ 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1,
73+ 0, 0, 0, 'a',
74+ 0, 0, 0,
75+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
76+ 0, 0, 0, 0, 0, 0,
77+ 0,
78+ 0, 0, 0, 0, 0, 0, 0, 0,
79+ 0, 0, 0,
80+ 0, 0, 0, 1, 1,
81+ }
82+
83+ // This is a genuine v1-layout payload: version 1 and no trailing bound
84+ // bytes. It is not a v2 payload with only its version byte changed.
85+ v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...)
86+ v1[4] = 1
87+ if _, err := UnmarshalHistory(v1); err == nil {
88+ t.Fatal("accepted a genuine v1 history payload")
89+ }
90+
91+ for i := 0; i < len(v2); i++ {
92+ if _, err := UnmarshalHistory(v2[:i]); err == nil {
93+ t.Fatalf("accepted truncated prefix of length %d", i)
94+ }
95+ }
96+ if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97+ t.Fatal("accepted trailing garbage")
98+ }
99+}
100+
12101 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
13102 indexed := renderer.Style{
14103 Bold: true,
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/binary"5 "encoding/binary"
5 "fmt"6 "fmt"
6 "math"7 "math"
@@ -9,6 +10,94 @@ import (
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 )11 )
11 12
13+func TestHistoryCodecV2Bytes(t *testing.T) {
14+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
15+ if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
16+ t.Fatalf("append: %v", err)
17+ }
18+ got, err := MarshalHistory(h.SealAndView())
19+ if err != nil {
20+ t.Fatalf("marshal: %v", err)
21+ }
22+
23+ want := []byte{
24+ 'V', 'T', 'H', '1', 2, // magic, version
25+ 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 1, // row count
27+ 0, 0, 0, 1, // cell count
28+ 0, 0, 0, 'a', // Rune
29+ 0, // flags
30+ 0, 0, // Attrs
31+ 0, 0, 0, 0, 0, 0, 0, 0, // Foreground
32+ 0, 0, 0, 0, 0, 0, 0, 0, // Background
33+ 0, 0, 0, // ForegroundRGB
34+ 0, 0, 0, // BackgroundRGB
35+ 0, // UnderlineStyle
36+ 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
37+ 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
38+ 0, 0, 0, 1, 1, // bound: End=1, Soft=true
39+ }
40+ if !bytes.Equal(got, want) {
41+ t.Fatalf("MarshalHistory() = % x, want % x", got, want)
42+ }
43+}
44+
45+func TestHistoryCodecRoundTripsBounds(t *testing.T) {
46+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
47+ bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
48+ for _, b := range bounds {
49+ if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
50+ t.Fatalf("append: %v", err)
51+ }
52+ }
53+ blob, err := MarshalHistory(h.SealAndView())
54+ if err != nil {
55+ t.Fatalf("marshal: %v", err)
56+ }
57+ view, err := UnmarshalHistory(blob)
58+ if err != nil {
59+ t.Fatalf("unmarshal: %v", err)
60+ }
61+ for i, want := range bounds {
62+ if got := view.Bound(i); got != want {
63+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
64+ }
65+ }
66+}
67+
68+func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
69+ // Canonical v2 fixture from TestHistoryCodecV2Bytes.
70+ v2 := []byte{
71+ 'V', 'T', 'H', '1', 2,
72+ 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1,
73+ 0, 0, 0, 'a',
74+ 0, 0, 0,
75+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
76+ 0, 0, 0, 0, 0, 0,
77+ 0,
78+ 0, 0, 0, 0, 0, 0, 0, 0,
79+ 0, 0, 0,
80+ 0, 0, 0, 1, 1,
81+ }
82+
83+ // This is a genuine v1-layout payload: version 1 and no trailing bound
84+ // bytes. It is not a v2 payload with only its version byte changed.
85+ v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...)
86+ v1[4] = 1
87+ if _, err := UnmarshalHistory(v1); err == nil {
88+ t.Fatal("accepted a genuine v1 history payload")
89+ }
90+
91+ for i := 0; i < len(v2); i++ {
92+ if _, err := UnmarshalHistory(v2[:i]); err == nil {
93+ t.Fatalf("accepted truncated prefix of length %d", i)
94+ }
95+ }
96+ if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97+ t.Fatal("accepted trailing garbage")
98+ }
99+}
100+
12 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {101 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
13 indexed := renderer.Style{102 indexed := renderer.Style{
14 Bold: true,103 Bold: true,
M pkg/vt/history_test.go +72 -5
@@ -10,7 +10,7 @@ import (
1010
1111 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
1212 t.Helper()
13- if err := history.Append(row); err != nil {
13+ if err := history.Append(row, LineBound{End: len(row)}); err != nil {
1414 t.Fatalf("append history row: %v", err)
1515 }
1616 }
@@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
204204 func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206206 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207- if err := history.Append(historyRow(text)); err != nil {
207+ if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208208 t.Fatalf("append %q: %v", text, err)
209209 }
210210 }
@@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
235235
236236 for _, tt := range tests {
237237 t.Run(tt.name, func(t *testing.T) {
238- if err := tt.history.Append(historyRow("row")); err != nil {
238+ row := historyRow("row")
239+ if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
239240 t.Fatalf("append error = %v, want nil", err)
240241 }
241242 if got := tt.history.Len(); got != 0 {
@@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
247248
248249 func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
249250 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
250- if err := history.Append(historyRow("ok")); err != nil {
251+ kept := historyRow("ok")
252+ if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
251253 t.Fatalf("append retained row: %v", err)
252254 }
253255 before := history.View()
254256
255- err := history.Append(historyRow("wide"))
257+ wide := historyRow("wide")
258+ err := history.Append(wide, LineBound{End: len(wide)})
256259 if !errors.Is(err, ErrHistoryRowTooWide) {
257260 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
258261 }
@@ -283,6 +286,70 @@ func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T)
283286 }
284287 }
285288
289+func TestHistoryAppendRetainsBounds(t *testing.T) {
290+ row := func(s string) []renderer.Cell {
291+ cells := make([]renderer.Cell, 0, len(s))
292+ for _, r := range s {
293+ cells = append(cells, renderer.Cell{Rune: r})
294+ }
295+ return cells
296+ }
297+
298+ tests := []struct {
299+ name string
300+ chunkRows int
301+ bounds []LineBound
302+ }{
303+ {
304+ name: "within the mutable tail",
305+ chunkRows: 8,
306+ bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
307+ },
308+ {
309+ name: "across a sealed chunk boundary",
310+ chunkRows: 2,
311+ bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
312+ },
313+ }
314+
315+ for _, tc := range tests {
316+ t.Run(tc.name, func(t *testing.T) {
317+ h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
318+ for i, b := range tc.bounds {
319+ if err := h.Append(row("abc"[:b.End]), b); err != nil {
320+ t.Fatalf("append %d: %v", i, err)
321+ }
322+ }
323+ view := h.SealAndView()
324+ for i, want := range tc.bounds {
325+ if got := view.Bound(i); got != want {
326+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
327+ }
328+ }
329+ })
330+ }
331+}
332+
333+func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334+ h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
335+ for i := range 5 {
336+ cells := []renderer.Cell{{Rune: rune('a' + i)}}
337+ if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
338+ t.Fatalf("append %d: %v", i, err)
339+ }
340+ }
341+ view := h.SealAndView()
342+ if view.Len() != 3 {
343+ t.Fatalf("Len() = %d, want 3", view.Len())
344+ }
345+ // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346+ for i, want := range []bool{true, false, true} {
347+ if got := view.Bound(i).Soft; got != want {
348+ t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
349+ }
350+ }
351+}
352+
286353 func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
287354 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
288355 if got, want := history.CellCap(), math.MaxInt; got != want {
@@ -10,7 +10,7 @@ import (
10 10
11 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {11 func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
12 t.Helper()12 t.Helper()
13- if err := history.Append(row); err != nil {13+ if err := history.Append(row, LineBound{End: len(row)}); err != nil {
14 t.Fatalf("append history row: %v", err)14 t.Fatalf("append history row: %v", err)
15 }15 }
16 }16 }
@@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
204 func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {204 func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})205 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206 for _, text := range []string{"aa", "bbb", "c", "dd"} {206 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207- if err := history.Append(historyRow(text)); err != nil {207+ if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208 t.Fatalf("append %q: %v", text, err)208 t.Fatalf("append %q: %v", text, err)
209 }209 }
210 }210 }
@@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
235 235
236 for _, tt := range tests {236 for _, tt := range tests {
237 t.Run(tt.name, func(t *testing.T) {237 t.Run(tt.name, func(t *testing.T) {
238- if err := tt.history.Append(historyRow("row")); err != nil {238+ row := historyRow("row")
239+ if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
239 t.Fatalf("append error = %v, want nil", err)240 t.Fatalf("append error = %v, want nil", err)
240 }241 }
241 if got := tt.history.Len(); got != 0 {242 if got := tt.history.Len(); got != 0 {
@@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
247 248
248 func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {249 func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
249 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})250 history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
250- if err := history.Append(historyRow("ok")); err != nil {251+ kept := historyRow("ok")
252+ if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
251 t.Fatalf("append retained row: %v", err)253 t.Fatalf("append retained row: %v", err)
252 }254 }
253 before := history.View()255 before := history.View()
254 256
255- err := history.Append(historyRow("wide"))257+ wide := historyRow("wide")
258+ err := history.Append(wide, LineBound{End: len(wide)})
256 if !errors.Is(err, ErrHistoryRowTooWide) {259 if !errors.Is(err, ErrHistoryRowTooWide) {
257 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)260 t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
258 }261 }
@@ -283,6 +286,70 @@ func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T)
283 }286 }
284 }287 }
285 288
289+func TestHistoryAppendRetainsBounds(t *testing.T) {
290+ row := func(s string) []renderer.Cell {
291+ cells := make([]renderer.Cell, 0, len(s))
292+ for _, r := range s {
293+ cells = append(cells, renderer.Cell{Rune: r})
294+ }
295+ return cells
296+ }
297+
298+ tests := []struct {
299+ name string
300+ chunkRows int
301+ bounds []LineBound
302+ }{
303+ {
304+ name: "within the mutable tail",
305+ chunkRows: 8,
306+ bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
307+ },
308+ {
309+ name: "across a sealed chunk boundary",
310+ chunkRows: 2,
311+ bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
312+ },
313+ }
314+
315+ for _, tc := range tests {
316+ t.Run(tc.name, func(t *testing.T) {
317+ h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
318+ for i, b := range tc.bounds {
319+ if err := h.Append(row("abc"[:b.End]), b); err != nil {
320+ t.Fatalf("append %d: %v", i, err)
321+ }
322+ }
323+ view := h.SealAndView()
324+ for i, want := range tc.bounds {
325+ if got := view.Bound(i); got != want {
326+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
327+ }
328+ }
329+ })
330+ }
331+}
332+
333+func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334+ h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
335+ for i := range 5 {
336+ cells := []renderer.Cell{{Rune: rune('a' + i)}}
337+ if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
338+ t.Fatalf("append %d: %v", i, err)
339+ }
340+ }
341+ view := h.SealAndView()
342+ if view.Len() != 3 {
343+ t.Fatalf("Len() = %d, want 3", view.Len())
344+ }
345+ // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346+ for i, want := range []bool{true, false, true} {
347+ if got := view.Bound(i).Soft; got != want {
348+ t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
349+ }
350+ }
351+}
352+
286 func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {353 func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
287 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})354 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
288 if got, want := history.CellCap(), math.MaxInt; got != want {355 if got, want := history.CellCap(), math.MaxInt; got != want {
M pkg/vt/screen.go +9 -0
@@ -111,6 +111,15 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
111111 // configured with NewScreenWithHistory.
112112 func (s *Screen) History() *History { return s.history }
113113
114+// LineBounds returns an owned copy of the live grid's per-row logical extents,
115+// indexed like Frame rows. It returns nil when the screen has no buffer.
116+func (s *Screen) LineBounds() []LineBound {
117+ if s == nil || s.buffer == nil {
118+ return nil
119+ }
120+ return append([]LineBound(nil), s.buffer.boundaries...)
121+}
122+
114123 func (s *Screen) Write(data []byte) {
115124 if len(s.escapeBuf) > 0 {
116125 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -111,6 +111,15 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
111 // configured with NewScreenWithHistory.111 // configured with NewScreenWithHistory.
112 func (s *Screen) History() *History { return s.history }112 func (s *Screen) History() *History { return s.history }
113 113
114+// LineBounds returns an owned copy of the live grid's per-row logical extents,
115+// indexed like Frame rows. It returns nil when the screen has no buffer.
116+func (s *Screen) LineBounds() []LineBound {
117+ if s == nil || s.buffer == nil {
118+ return nil
119+ }
120+ return append([]LineBound(nil), s.buffer.boundaries...)
121+}
122+
114 func (s *Screen) Write(data []byte) {123 func (s *Screen) Write(data []byte) {
115 if len(s.escapeBuf) > 0 {124 if len(s.escapeBuf) > 0 {
116 combined := make([]byte, 0, len(s.escapeBuf)+len(data))125 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
M pkg/vt/screen_csi.go +1 -1
@@ -409,7 +409,7 @@ func (s *Screen) clearScreenMode(mode int) {
409409 s.Frame.Cells[i] = blank
410410 }
411411 for y := range s.buffer.boundaries {
412- s.buffer.boundaries[y] = lineBoundary{soft: s.buffer.boundaries[y].soft}
412+ s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
413413 }
414414 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
415415 default:
@@ -409,7 +409,7 @@ func (s *Screen) clearScreenMode(mode int) {
409 s.Frame.Cells[i] = blank409 s.Frame.Cells[i] = blank
410 }410 }
411 for y := range s.buffer.boundaries {411 for y := range s.buffer.boundaries {
412- s.buffer.boundaries[y] = lineBoundary{soft: s.buffer.boundaries[y].soft}412+ s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
413 }413 }
414 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})414 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
415 default:415 default:
M pkg/vt/screen_resize.go +3 -3
@@ -25,10 +25,10 @@ func (s *Screen) Resize(width, height int) {
2525 if saved != nil && saved.saved {
2626 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
2727 }
28- evicted := b.resize(width, height, active, savedPoint)
28+ evicted, evictedBounds := b.resize(width, height, active, savedPoint)
2929 if evict {
30- for _, line := range evicted {
31- s.recordEvicted(line)
30+ for i, line := range evicted {
31+ s.recordEvicted(line, evictedBounds[i])
3232 }
3333 }
3434 if savedPoint != nil {
@@ -25,10 +25,10 @@ func (s *Screen) Resize(width, height int) {
25 if saved != nil && saved.saved {25 if saved != nil && saved.saved {
26 savedPoint = &bufferCursor{row: saved.row, col: saved.col}26 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
27 }27 }
28- evicted := b.resize(width, height, active, savedPoint)28+ evicted, evictedBounds := b.resize(width, height, active, savedPoint)
29 if evict {29 if evict {
30- for _, line := range evicted {30+ for i, line := range evicted {
31- s.recordEvicted(line)31+ s.recordEvicted(line, evictedBounds[i])
32 }32 }
33 }33 }
34 if savedPoint != nil {34 if savedPoint != nil {
M pkg/vt/screen_scroll.go +25 -11
@@ -6,19 +6,25 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9-func (s *Screen) index() {
9+// index moves to the next physical row, scrolling the region when the cursor
10+// already sits on its last row. It reports whether the row the cursor was on
11+// survived that scroll by moving up to s.Row-1, which only a deferred wrap
12+// cares about: a wrap must relink to the row it flowed out of, and
13+// buffer.scrollUp severs that link on the way. Callers that merely advance,
14+// such as LF and ESC D, ignore the result.
15+func (s *Screen) index() (movedUp bool) {
1016 if s.Frame.Height == 0 {
11- return
17+ return false
1218 }
1319 if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
14- s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
15- return
20+ return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
1621 }
1722 if s.Row+1 < s.Frame.Height {
1823 s.Row++
19- return
24+ return false
2025 }
2126 s.Row = s.Frame.Height - 1
27+ return false
2228 }
2329
2430 func (s *Screen) nextLine() {
@@ -56,13 +62,18 @@ func (s *Screen) scrollDownBy(n int) {
5662 s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
5763 }
5864
59-func (s *Screen) scrollUpRegion(top, bottom, n int) {
65+// scrollUpRegion scrolls [top,bottom] up by n. It reports whether any row
66+// survived by moving up: a region exactly n rows tall is blanked in place, so
67+// the receiving range [top,bottom-n] is empty and nothing survives. Reporting
68+// what the scroll observed keeps callers from re-deriving a predicate that this
69+// function may have declined to act on at all.
70+func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
6071 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
61- return
72+ return false
6273 }
6374 top, bottom, ok := s.normalizedRegion(top, bottom)
6475 if !ok {
65- return
76+ return false
6677 }
6778 w := s.Frame.Width
6879 height := bottom - top + 1
@@ -77,6 +88,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
7788 s.buffer.scrollUp(top, bottom, n)
7889 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
7990 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
91+ return bottom-n >= top
8092 }
8193
8294 func (s *Screen) emitLineEvicted(top, n int) {
@@ -85,14 +97,16 @@ func (s *Screen) emitLineEvicted(top, n int) {
8597 if s.alternate != nil || top != 0 {
8698 return
8799 }
100+ // Read boundaries before the caller rotates the frame: a soft link belongs
101+ // to the row it follows, and rotation reassigns row indices.
88102 for y := top; y < top+n; y++ {
89- s.recordEvicted(s.Frame.Row(y))
103+ s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y))
90104 }
91105 }
92106
93-func (s *Screen) recordEvicted(row []renderer.Cell) {
107+func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) {
94108 if s.history != nil {
95- err := s.history.Append(row)
109+ err := s.history.Append(row, bound)
96110 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
97111 panic(err)
98112 }
@@ -6,19 +6,25 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9-func (s *Screen) index() {9+// index moves to the next physical row, scrolling the region when the cursor
10+// already sits on its last row. It reports whether the row the cursor was on
11+// survived that scroll by moving up to s.Row-1, which only a deferred wrap
12+// cares about: a wrap must relink to the row it flowed out of, and
13+// buffer.scrollUp severs that link on the way. Callers that merely advance,
14+// such as LF and ESC D, ignore the result.
15+func (s *Screen) index() (movedUp bool) {
10 if s.Frame.Height == 0 {16 if s.Frame.Height == 0 {
11- return17+ return false
12 }18 }
13 if s.Row == s.scrollBottom && s.Row >= s.scrollTop {19 if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
14- s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)20+ return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
15- return
16 }21 }
17 if s.Row+1 < s.Frame.Height {22 if s.Row+1 < s.Frame.Height {
18 s.Row++23 s.Row++
19- return24+ return false
20 }25 }
21 s.Row = s.Frame.Height - 126 s.Row = s.Frame.Height - 1
27+ return false
22 }28 }
23 29
24 func (s *Screen) nextLine() {30 func (s *Screen) nextLine() {
@@ -56,13 +62,18 @@ func (s *Screen) scrollDownBy(n int) {
56 s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)62 s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
57 }63 }
58 64
59-func (s *Screen) scrollUpRegion(top, bottom, n int) {65+// scrollUpRegion scrolls [top,bottom] up by n. It reports whether any row
66+// survived by moving up: a region exactly n rows tall is blanked in place, so
67+// the receiving range [top,bottom-n] is empty and nothing survives. Reporting
68+// what the scroll observed keeps callers from re-deriving a predicate that this
69+// function may have declined to act on at all.
70+func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
60 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {71 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
61- return72+ return false
62 }73 }
63 top, bottom, ok := s.normalizedRegion(top, bottom)74 top, bottom, ok := s.normalizedRegion(top, bottom)
64 if !ok {75 if !ok {
65- return76+ return false
66 }77 }
67 w := s.Frame.Width78 w := s.Frame.Width
68 height := bottom - top + 179 height := bottom - top + 1
@@ -77,6 +88,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
77 s.buffer.scrollUp(top, bottom, n)88 s.buffer.scrollUp(top, bottom, n)
78 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})89 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
79 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})90 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
91+ return bottom-n >= top
80 }92 }
81 93
82 func (s *Screen) emitLineEvicted(top, n int) {94 func (s *Screen) emitLineEvicted(top, n int) {
@@ -85,14 +97,16 @@ func (s *Screen) emitLineEvicted(top, n int) {
85 if s.alternate != nil || top != 0 {97 if s.alternate != nil || top != 0 {
86 return98 return
87 }99 }
100+ // Read boundaries before the caller rotates the frame: a soft link belongs
101+ // to the row it follows, and rotation reassigns row indices.
88 for y := top; y < top+n; y++ {102 for y := top; y < top+n; y++ {
89- s.recordEvicted(s.Frame.Row(y))103+ s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y))
90 }104 }
91 }105 }
92 106
93-func (s *Screen) recordEvicted(row []renderer.Cell) {107+func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) {
94 if s.history != nil {108 if s.history != nil {
95- err := s.history.Append(row)109+ err := s.history.Append(row, bound)
96 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {110 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
97 panic(err)111 panic(err)
98 }112 }
M pkg/vt/screen_write.go +14 -2
@@ -40,6 +40,18 @@ func (s *Screen) putRune(r rune) {
4040 }
4141 }
4242
43+// relinkWrap restores the soft link a deferred wrap just set when index()
44+// scrolled the row out from under it. buffer.scrollUp severs the last moved
45+// row on purpose — a linefeed there really does stop preceding anything — but
46+// a wrap is the one case where that row flowed into the row below it, so the
47+// link has to be put back after the rotation moved it to s.Row-1. movedUp is
48+// false when nothing survived the scroll, and then there is no row to link.
49+func (s *Screen) relinkWrap(movedUp bool) {
50+ if movedUp {
51+ s.buffer.continueRow(s.Row - 1)
52+ }
53+}
54+
4355 func (s *Screen) putPrintable(r rune) {
4456 w := renderer.RuneWidth(r)
4557 // Skip combining marks and zero-width characters.
@@ -53,7 +65,7 @@ func (s *Screen) putPrintable(r rune) {
5365 if s.Col >= s.Frame.Width {
5466 s.buffer.soft(s.Row)
5567 s.Col = 0
56- s.index()
68+ s.relinkWrap(s.index())
5769 }
5870 if s.Row >= s.Frame.Height {
5971 s.Row = s.Frame.Height - 1
@@ -80,7 +92,7 @@ func (s *Screen) putPrintable(r rune) {
8092 s.buffer.continueRow(s.Row)
8193 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
8294 s.Col = 0
83- s.index()
95+ s.relinkWrap(s.index())
8496 if s.Row >= s.Frame.Height {
8597 s.Row = s.Frame.Height - 1
8698 }
@@ -40,6 +40,18 @@ func (s *Screen) putRune(r rune) {
40 }40 }
41 }41 }
42 42
43+// relinkWrap restores the soft link a deferred wrap just set when index()
44+// scrolled the row out from under it. buffer.scrollUp severs the last moved
45+// row on purpose — a linefeed there really does stop preceding anything — but
46+// a wrap is the one case where that row flowed into the row below it, so the
47+// link has to be put back after the rotation moved it to s.Row-1. movedUp is
48+// false when nothing survived the scroll, and then there is no row to link.
49+func (s *Screen) relinkWrap(movedUp bool) {
50+ if movedUp {
51+ s.buffer.continueRow(s.Row - 1)
52+ }
53+}
54+
43 func (s *Screen) putPrintable(r rune) {55 func (s *Screen) putPrintable(r rune) {
44 w := renderer.RuneWidth(r)56 w := renderer.RuneWidth(r)
45 // Skip combining marks and zero-width characters.57 // Skip combining marks and zero-width characters.
@@ -53,7 +65,7 @@ func (s *Screen) putPrintable(r rune) {
53 if s.Col >= s.Frame.Width {65 if s.Col >= s.Frame.Width {
54 s.buffer.soft(s.Row)66 s.buffer.soft(s.Row)
55 s.Col = 067 s.Col = 0
56- s.index()68+ s.relinkWrap(s.index())
57 }69 }
58 if s.Row >= s.Frame.Height {70 if s.Row >= s.Frame.Height {
59 s.Row = s.Frame.Height - 171 s.Row = s.Frame.Height - 1
@@ -80,7 +92,7 @@ func (s *Screen) putPrintable(r rune) {
80 s.buffer.continueRow(s.Row)92 s.buffer.continueRow(s.Row)
81 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})93 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
82 s.Col = 094 s.Col = 0
83- s.index()95+ s.relinkWrap(s.index())
84 if s.Row >= s.Frame.Height {96 if s.Row >= s.Frame.Height {
85 s.Row = s.Frame.Height - 197 s.Row = s.Frame.Height - 1
86 }98 }
M pkg/vt/screen_write_test.go +44 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
78 )
89
910 func TestWritePrintableAndUTF8(t *testing.T) {
@@ -922,3 +923,46 @@ func TestC1Controls(t *testing.T) {
922923 t.Run(tt.name, tt.run)
923924 }
924925 }
926+
927+func TestScreenLineBoundsDescribesTheLiveGrid(t *testing.T) {
928+ tests := []struct {
929+ name string
930+ width int
931+ height int
932+ write string
933+ want []LineBound
934+ // checkOwned exercises the caller-owned-copy guarantee: it only makes
935+ // sense to check once per LineBounds() call, so it lives on the case
936+ // whose write actually produces a soft-wrapped row 0 to corrupt.
937+ checkOwned bool
938+ }{
939+ {
940+ name: "wraps after column width",
941+ width: 4,
942+ height: 3,
943+ write: "abcdef", // wraps after column 4
944+ want: []LineBound{
945+ {End: 4, Soft: true}, // row 0: filled the row and wrapped
946+ {End: 2, Soft: false}, // row 1: "ef", no wrap
947+ {End: 0, Soft: false}, // row 2: untouched
948+ },
949+ checkOwned: true,
950+ },
951+ }
952+
953+ for _, tt := range tests {
954+ t.Run(tt.name, func(t *testing.T) {
955+ s := NewScreen(tt.width, tt.height)
956+ s.Write([]byte(tt.write))
957+
958+ bounds := s.LineBounds()
959+ require.Equal(t, tt.want, bounds)
960+
961+ if tt.checkOwned {
962+ // The result is owned by the caller.
963+ bounds[0] = LineBound{}
964+ require.True(t, s.LineBounds()[0].Soft, "mutating the result changed the screen's own boundaries")
965+ }
966+ })
967+ }
968+}
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
7 )8 )
8 9
9 func TestWritePrintableAndUTF8(t *testing.T) {10 func TestWritePrintableAndUTF8(t *testing.T) {
@@ -922,3 +923,46 @@ func TestC1Controls(t *testing.T) {
922 t.Run(tt.name, tt.run)923 t.Run(tt.name, tt.run)
923 }924 }
924 }925 }
926+
927+func TestScreenLineBoundsDescribesTheLiveGrid(t *testing.T) {
928+ tests := []struct {
929+ name string
930+ width int
931+ height int
932+ write string
933+ want []LineBound
934+ // checkOwned exercises the caller-owned-copy guarantee: it only makes
935+ // sense to check once per LineBounds() call, so it lives on the case
936+ // whose write actually produces a soft-wrapped row 0 to corrupt.
937+ checkOwned bool
938+ }{
939+ {
940+ name: "wraps after column width",
941+ width: 4,
942+ height: 3,
943+ write: "abcdef", // wraps after column 4
944+ want: []LineBound{
945+ {End: 4, Soft: true}, // row 0: filled the row and wrapped
946+ {End: 2, Soft: false}, // row 1: "ef", no wrap
947+ {End: 0, Soft: false}, // row 2: untouched
948+ },
949+ checkOwned: true,
950+ },
951+ }
952+
953+ for _, tt := range tests {
954+ t.Run(tt.name, func(t *testing.T) {
955+ s := NewScreen(tt.width, tt.height)
956+ s.Write([]byte(tt.write))
957+
958+ bounds := s.LineBounds()
959+ require.Equal(t, tt.want, bounds)
960+
961+ if tt.checkOwned {
962+ // The result is owned by the caller.
963+ bounds[0] = LineBound{}
964+ require.True(t, s.LineBounds()[0].Soft, "mutating the result changed the screen's own boundaries")
965+ }
966+ })
967+ }
968+}
M pkg/vt/scrollback_test.go +59 -0
@@ -99,6 +99,65 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
9999 }
100100 }
101101
102+func TestEvictedLinesCarryTheirBound(t *testing.T) {
103+ tests := []struct {
104+ name string
105+ evict func(s *Screen)
106+ wantSoft bool
107+ }{
108+ {
109+ name: "scroll evicts a soft-wrapped row",
110+ evict: func(s *Screen) {
111+ // Filling all four columns only leaves deferred wrap pending. The
112+ // fifth printable character triggers the soft wrap before scrolling.
113+ s.Write([]byte("abcde"))
114+ s.Write([]byte("\r\n\r\n\r\n\r\n"))
115+ },
116+ wantSoft: true,
117+ },
118+ {
119+ name: "scroll evicts a hard row",
120+ evict: func(s *Screen) {
121+ s.Write([]byte("ab\r\n\r\n\r\n\r\n"))
122+ },
123+ wantSoft: false,
124+ },
125+ }
126+
127+ for _, tc := range tests {
128+ t.Run(tc.name, func(t *testing.T) {
129+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024})
130+ tc.evict(s)
131+ view := s.History().SealAndView()
132+ if view.Len() == 0 {
133+ t.Fatal("nothing was evicted to history")
134+ }
135+ if got := view.Bound(0).Soft; got != tc.wantSoft {
136+ t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft)
137+ }
138+ })
139+ }
140+}
141+
142+func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {
143+ s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024})
144+ // Fill the grid with wrapped content, then shrink so rows are evicted by
145+ // the reflow path in buffer.resize rather than by scrolling.
146+ s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
147+ s.Resize(4, 2)
148+
149+ view := s.History().SealAndView()
150+ if view.Len() == 0 {
151+ t.Fatal("reflow evicted nothing to history")
152+ }
153+ for i := range view.Len() {
154+ bound := view.Bound(i)
155+ if bound.End == 0 {
156+ t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i)
157+ }
158+ }
159+}
160+
102161 func equalStrings(a, b []string) bool {
103162 if len(a) != len(b) {
104163 return false
@@ -99,6 +99,65 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
99 }99 }
100 }100 }
101 101
102+func TestEvictedLinesCarryTheirBound(t *testing.T) {
103+ tests := []struct {
104+ name string
105+ evict func(s *Screen)
106+ wantSoft bool
107+ }{
108+ {
109+ name: "scroll evicts a soft-wrapped row",
110+ evict: func(s *Screen) {
111+ // Filling all four columns only leaves deferred wrap pending. The
112+ // fifth printable character triggers the soft wrap before scrolling.
113+ s.Write([]byte("abcde"))
114+ s.Write([]byte("\r\n\r\n\r\n\r\n"))
115+ },
116+ wantSoft: true,
117+ },
118+ {
119+ name: "scroll evicts a hard row",
120+ evict: func(s *Screen) {
121+ s.Write([]byte("ab\r\n\r\n\r\n\r\n"))
122+ },
123+ wantSoft: false,
124+ },
125+ }
126+
127+ for _, tc := range tests {
128+ t.Run(tc.name, func(t *testing.T) {
129+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024})
130+ tc.evict(s)
131+ view := s.History().SealAndView()
132+ if view.Len() == 0 {
133+ t.Fatal("nothing was evicted to history")
134+ }
135+ if got := view.Bound(0).Soft; got != tc.wantSoft {
136+ t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft)
137+ }
138+ })
139+ }
140+}
141+
142+func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {
143+ s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024})
144+ // Fill the grid with wrapped content, then shrink so rows are evicted by
145+ // the reflow path in buffer.resize rather than by scrolling.
146+ s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
147+ s.Resize(4, 2)
148+
149+ view := s.History().SealAndView()
150+ if view.Len() == 0 {
151+ t.Fatal("reflow evicted nothing to history")
152+ }
153+ for i := range view.Len() {
154+ bound := view.Bound(i)
155+ if bound.End == 0 {
156+ t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i)
157+ }
158+ }
159+}
160+
102 func equalStrings(a, b []string) bool {161 func equalStrings(a, b []string) bool {
103 if len(a) != len(b) {162 if len(a) != len(b) {
104 return false163 return false
M pkg/vt/snapshot.go +11 -10
@@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
102102 if len(view.chunks) == 1 {
103103 h.evictUntil(len(view.chunks[0].rows), view.Cells())
104104 h.tail = view.chunks[0].rows
105+ h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
105106 h.rows += len(h.tail)
106107 h.cells += view.Cells()
107108 }
@@ -129,11 +130,11 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
129130 if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {
130131 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
131132 }
132- boundaries := make([]lineBoundary, frame.Height)
133+ boundaries := make([]LineBound, frame.Height)
133134 for y := range boundaries {
134135 for x := frame.Width - 1; x >= 0; x-- {
135136 if !frame.At(x, y).Equal(renderer.BlankCell()) {
136- boundaries[y].end = x + 1
137+ boundaries[y].End = x + 1
137138 break
138139 }
139140 }
@@ -146,7 +147,7 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
146147 // after releasing that lock.
147148 type PrimaryVisibleSnapshot struct {
148149 frame renderer.Frame
149- boundaries []lineBoundary
150+ boundaries []LineBound
150151 }
151152
152153 // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line
@@ -162,7 +163,7 @@ func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot {
162163 }
163164 return PrimaryVisibleSnapshot{
164165 frame: b.frame.Clone(),
165- boundaries: append([]lineBoundary(nil), b.boundaries...),
166+ boundaries: append([]LineBound(nil), b.boundaries...),
166167 }
167168 }
168169
@@ -184,7 +185,7 @@ func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
184185 return s.PrimaryVisibleSnapshot().Marshal()
185186 }
186187
187-func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
188+func marshalVisible(frame renderer.Frame, boundaries []LineBound) ([]byte, error) {
188189 _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)
189190 if !ok || len(boundaries) != frame.Height {
190191 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
@@ -203,8 +204,8 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er
203204 }
204205 }
205206 for _, boundary := range boundaries {
206- out = binary.BigEndian.AppendUint32(out, uint32(boundary.end))
207- if boundary.soft {
207+ out = binary.BigEndian.AppendUint32(out, uint32(boundary.End))
208+ if boundary.Soft {
208209 out = append(out, 1)
209210 } else {
210211 out = append(out, 0)
@@ -269,7 +270,7 @@ func UnmarshalVisible(data []byte) (renderer.Frame, error) {
269270 return frame, err
270271 }
271272
272-func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
273+func unmarshalVisible(data []byte) (renderer.Frame, []LineBound, error) {
273274 if _, err := PreflightVisibleBlob(data); err != nil {
274275 return renderer.Frame{}, nil, err
275276 }
@@ -283,10 +284,10 @@ func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
283284 frame.Set(x, y, cell)
284285 }
285286 }
286- boundaries := make([]lineBoundary, height)
287+ boundaries := make([]LineBound, height)
287288 offset := 13 + int(cellBytes)
288289 for y := range boundaries {
289- boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1}
290+ boundaries[y] = LineBound{End: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), Soft: data[offset+4] == 1}
290291 offset += 5
291292 }
292293 return frame, boundaries, nil
@@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
102 if len(view.chunks) == 1 {102 if len(view.chunks) == 1 {
103 h.evictUntil(len(view.chunks[0].rows), view.Cells())103 h.evictUntil(len(view.chunks[0].rows), view.Cells())
104 h.tail = view.chunks[0].rows104 h.tail = view.chunks[0].rows
105+ h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
105 h.rows += len(h.tail)106 h.rows += len(h.tail)
106 h.cells += view.Cells()107 h.cells += view.Cells()
107 }108 }
@@ -129,11 +130,11 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
129 if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {130 if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {
130 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)131 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
131 }132 }
132- boundaries := make([]lineBoundary, frame.Height)133+ boundaries := make([]LineBound, frame.Height)
133 for y := range boundaries {134 for y := range boundaries {
134 for x := frame.Width - 1; x >= 0; x-- {135 for x := frame.Width - 1; x >= 0; x-- {
135 if !frame.At(x, y).Equal(renderer.BlankCell()) {136 if !frame.At(x, y).Equal(renderer.BlankCell()) {
136- boundaries[y].end = x + 1137+ boundaries[y].End = x + 1
137 break138 break
138 }139 }
139 }140 }
@@ -146,7 +147,7 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
146 // after releasing that lock.147 // after releasing that lock.
147 type PrimaryVisibleSnapshot struct {148 type PrimaryVisibleSnapshot struct {
148 frame renderer.Frame149 frame renderer.Frame
149- boundaries []lineBoundary150+ boundaries []LineBound
150 }151 }
151 152
152 // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line153 // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line
@@ -162,7 +163,7 @@ func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot {
162 }163 }
163 return PrimaryVisibleSnapshot{164 return PrimaryVisibleSnapshot{
164 frame: b.frame.Clone(),165 frame: b.frame.Clone(),
165- boundaries: append([]lineBoundary(nil), b.boundaries...),166+ boundaries: append([]LineBound(nil), b.boundaries...),
166 }167 }
167 }168 }
168 169
@@ -184,7 +185,7 @@ func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
184 return s.PrimaryVisibleSnapshot().Marshal()185 return s.PrimaryVisibleSnapshot().Marshal()
185 }186 }
186 187
187-func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {188+func marshalVisible(frame renderer.Frame, boundaries []LineBound) ([]byte, error) {
188 _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)189 _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)
189 if !ok || len(boundaries) != frame.Height {190 if !ok || len(boundaries) != frame.Height {
190 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)191 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
@@ -203,8 +204,8 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er
203 }204 }
204 }205 }
205 for _, boundary := range boundaries {206 for _, boundary := range boundaries {
206- out = binary.BigEndian.AppendUint32(out, uint32(boundary.end))207+ out = binary.BigEndian.AppendUint32(out, uint32(boundary.End))
207- if boundary.soft {208+ if boundary.Soft {
208 out = append(out, 1)209 out = append(out, 1)
209 } else {210 } else {
210 out = append(out, 0)211 out = append(out, 0)
@@ -269,7 +270,7 @@ func UnmarshalVisible(data []byte) (renderer.Frame, error) {
269 return frame, err270 return frame, err
270 }271 }
271 272
272-func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {273+func unmarshalVisible(data []byte) (renderer.Frame, []LineBound, error) {
273 if _, err := PreflightVisibleBlob(data); err != nil {274 if _, err := PreflightVisibleBlob(data); err != nil {
274 return renderer.Frame{}, nil, err275 return renderer.Frame{}, nil, err
275 }276 }
@@ -283,10 +284,10 @@ func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
283 frame.Set(x, y, cell)284 frame.Set(x, y, cell)
284 }285 }
285 }286 }
286- boundaries := make([]lineBoundary, height)287+ boundaries := make([]LineBound, height)
287 offset := 13 + int(cellBytes)288 offset := 13 + int(cellBytes)
288 for y := range boundaries {289 for y := range boundaries {
289- boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1}290+ boundaries[y] = LineBound{End: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), Soft: data[offset+4] == 1}
290 offset += 5291 offset += 5
291 }292 }
292 return frame, boundaries, nil293 return frame, boundaries, nil
M pkg/vt/snapshot_test.go +5 -11
@@ -10,15 +10,9 @@ import (
1010
1111 func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
1212 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
13- if err := history.Append(historyRow("AAAA")); err != nil {
14- t.Fatal(err)
15- }
16- if err := history.Append(historyRow("BBBB")); err != nil {
17- t.Fatal(err)
18- }
19- if err := history.Append(historyRow("CCCC")); err != nil {
20- t.Fatal(err)
21- }
13+ requireHistoryAppend(t, history, historyRow("AAAA"))
14+ requireHistoryAppend(t, history, historyRow("BBBB"))
15+ requireHistoryAppend(t, history, historyRow("CCCC"))
2216 sealed := history.View().Chunk(0)
2317
2418 view := history.SnapshotView()
@@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
5852
5953 restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)
6054 require.NoError(t, err)
61- require.NoError(t, restored.Append(historyRow("CCCC")))
55+ requireHistoryAppend(t, restored, historyRow("CCCC"))
6256
6357 require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))
6458 require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")
@@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
6862 func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
6963 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
7064 for _, text := range []string{"a", "b", "cc"} {
71- require.NoError(t, history.Append(historyRow(text)))
65+ requireHistoryAppend(t, history, historyRow(text))
7266 }
7367 view := history.SnapshotView()
7468 sealed := make([][]byte, view.ChunkCount())
@@ -10,15 +10,9 @@ import (
10 10
11 func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {11 func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
12 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})12 history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
13- if err := history.Append(historyRow("AAAA")); err != nil {13+ requireHistoryAppend(t, history, historyRow("AAAA"))
14- t.Fatal(err)14+ requireHistoryAppend(t, history, historyRow("BBBB"))
15- }15+ requireHistoryAppend(t, history, historyRow("CCCC"))
16- if err := history.Append(historyRow("BBBB")); err != nil {
17- t.Fatal(err)
18- }
19- if err := history.Append(historyRow("CCCC")); err != nil {
20- t.Fatal(err)
21- }
22 sealed := history.View().Chunk(0)16 sealed := history.View().Chunk(0)
23 17
24 view := history.SnapshotView()18 view := history.SnapshotView()
@@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
58 52
59 restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)53 restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)
60 require.NoError(t, err)54 require.NoError(t, err)
61- require.NoError(t, restored.Append(historyRow("CCCC")))55+ requireHistoryAppend(t, restored, historyRow("CCCC"))
62 56
63 require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))57 require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))
64 require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")58 require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")
@@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
68 func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {62 func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
69 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})63 history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
70 for _, text := range []string{"a", "b", "cc"} {64 for _, text := range []string{"a", "b", "cc"} {
71- require.NoError(t, history.Append(historyRow(text)))65+ requireHistoryAppend(t, history, historyRow(text))
72 }66 }
73 view := history.SnapshotView()67 view := history.SnapshotView()
74 sealed := make([][]byte, view.ChunkCount())68 sealed := make([][]byte, view.ChunkCount())
A pkg/vt/wrap_scroll_soft_test.go +135 -0
@@ -0,0 +1,135 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+// A deferred wrap out of the last row of a scroll region scrolls that region,
10+// and buffer.scrollUp deliberately severs the link on the last moved row. That
11+// sever is right for a linefeed and wrong when the wrap itself caused the
12+// scroll, so these cases pin both sides of the distinction.
13+func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) {
14+ const width = 8
15+ cases := []struct {
16+ name string
17+ height int
18+ input string
19+ // wantBounds is indexed like the live grid.
20+ wantBounds map[int]LineBound
21+ // wantRows pins the grid text so a bound is never read off a row that
22+ // holds something other than what the case describes.
23+ wantRows map[int]string
24+ }{
25+ {
26+ // The bug: the cursor sits on the last row, as it does in any full
27+ // shell session, so the wrap scrolls and the link must survive.
28+ name: "narrow wrap out of the bottom row keeps the link",
29+ height: 4,
30+ input: "1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij",
31+ wantBounds: map[int]LineBound{
32+ 2: {End: 8, Soft: true},
33+ 3: {End: 2},
34+ },
35+ wantRows: map[int]string{2: "abcdefgh", 3: "ij "},
36+ },
37+ {
38+ // Same shape at the wide-rune wrap site: the wide rune does not fit
39+ // in the last column, so the writer abandons that cell and wraps.
40+ name: "wide-rune wrap out of the bottom row keeps the link",
41+ height: 4,
42+ input: "1\r\n2\r\n3\r\nabcdefg漢",
43+ wantBounds: map[int]LineBound{
44+ 2: {End: 7, Soft: true},
45+ 3: {End: 2},
46+ },
47+ wantRows: map[int]string{2: "abcdefg "},
48+ },
49+ {
50+ // The sever that must survive: a full-width row ending in an explicit
51+ // newline is a hard line that merely happens to fill the grid.
52+ name: "explicit newline after a full-width bottom row severs",
53+ height: 4,
54+ input: "1\r\n2\r\n3\r\nabcdefgh\r\n",
55+ wantBounds: map[int]LineBound{
56+ 2: {End: 8, Soft: false},
57+ },
58+ wantRows: map[int]string{2: "abcdefgh"},
59+ },
60+ {
61+ // A DECSTBM region scroll is a local mutation, not a wrap: it must
62+ // still sever at the row above the region (top-1), whose
63+ // continuation the scroll just destroyed, and at the last moved row.
64+ name: "DECSTBM region scroll severs at both edges",
65+ height: 4,
66+ input: "abcdefghij" + "\x1b[2;3r" + "\x1b[3;1H" + "\n",
67+ wantBounds: map[int]LineBound{
68+ 0: {End: 8, Soft: false},
69+ 1: {End: 0, Soft: false},
70+ },
71+ wantRows: map[int]string{0: "abcdefgh"},
72+ },
73+ {
74+ // A region exactly one row tall blanks the wrapped row in place
75+ // instead of moving it up, so no surviving grid row may be linked.
76+ // setScrollRegion rejects top >= bottom, so the only reachable
77+ // one-row region is a one-row screen.
78+ name: "wrap on a one-row screen links nothing on the grid",
79+ height: 1,
80+ input: "abcdefghij",
81+ wantBounds: map[int]LineBound{
82+ 0: {End: 2, Soft: false},
83+ },
84+ wantRows: map[int]string{0: "ij "},
85+ },
86+ }
87+ for _, tc := range cases {
88+ t.Run(tc.name, func(t *testing.T) {
89+ s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
90+ s.Write([]byte(tc.input))
91+
92+ bounds := s.LineBounds()
93+ for y, want := range tc.wantBounds {
94+ require.Equal(t, want, bounds[y], "LineBounds()[%d]", y)
95+ }
96+ for y, want := range tc.wantRows {
97+ require.Equal(t, want, lineText(s, y), "row %d text", y)
98+ }
99+ })
100+ }
101+}
102+
103+// The soft link also drives reflow, so restoring it fixes resize too: a line
104+// wrapped out of the bottom row used to be two unrelated physical rows, and
105+// widening the pane left it split instead of rejoining it.
106+func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) {
107+ s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxCells: 4096})
108+ s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij"))
109+
110+ s.Resize(16, 4)
111+
112+ require.Equal(t, "abcdefghij ", lineText(s, 2), "the wrapped line must rejoin on one row")
113+ require.Equal(t, LineBound{End: 10}, s.LineBounds()[2])
114+ require.Equal(t, " ", lineText(s, 3), "its continuation row must be consumed by the rejoin")
115+}
116+
117+// A wrap that scrolls the primary screen must hand history the same soft link
118+// it leaves on the grid, so a line wrapped at the bottom of a full pane still
119+// rejoins after it scrolls away.
120+func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) {
121+ const (
122+ width = 8
123+ height = 4
124+ )
125+ s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
126+ // Wrap out of the bottom row, then push both physical rows into history.
127+ s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n"))
128+
129+ view := s.History().SealAndView()
130+ require.Equal(t, 7, view.Len(), "history row count")
131+ require.Equal(t, "abcdefgh", rowString(view.BorrowedRow(5)))
132+ require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft")
133+ require.Equal(t, "ij ", rowString(view.BorrowedRow(6)))
134+ require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end")
135+}
@@ -0,0 +1,135 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+// A deferred wrap out of the last row of a scroll region scrolls that region,
10+// and buffer.scrollUp deliberately severs the link on the last moved row. That
11+// sever is right for a linefeed and wrong when the wrap itself caused the
12+// scroll, so these cases pin both sides of the distinction.
13+func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) {
14+ const width = 8
15+ cases := []struct {
16+ name string
17+ height int
18+ input string
19+ // wantBounds is indexed like the live grid.
20+ wantBounds map[int]LineBound
21+ // wantRows pins the grid text so a bound is never read off a row that
22+ // holds something other than what the case describes.
23+ wantRows map[int]string
24+ }{
25+ {
26+ // The bug: the cursor sits on the last row, as it does in any full
27+ // shell session, so the wrap scrolls and the link must survive.
28+ name: "narrow wrap out of the bottom row keeps the link",
29+ height: 4,
30+ input: "1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij",
31+ wantBounds: map[int]LineBound{
32+ 2: {End: 8, Soft: true},
33+ 3: {End: 2},
34+ },
35+ wantRows: map[int]string{2: "abcdefgh", 3: "ij "},
36+ },
37+ {
38+ // Same shape at the wide-rune wrap site: the wide rune does not fit
39+ // in the last column, so the writer abandons that cell and wraps.
40+ name: "wide-rune wrap out of the bottom row keeps the link",
41+ height: 4,
42+ input: "1\r\n2\r\n3\r\nabcdefg漢",
43+ wantBounds: map[int]LineBound{
44+ 2: {End: 7, Soft: true},
45+ 3: {End: 2},
46+ },
47+ wantRows: map[int]string{2: "abcdefg "},
48+ },
49+ {
50+ // The sever that must survive: a full-width row ending in an explicit
51+ // newline is a hard line that merely happens to fill the grid.
52+ name: "explicit newline after a full-width bottom row severs",
53+ height: 4,
54+ input: "1\r\n2\r\n3\r\nabcdefgh\r\n",
55+ wantBounds: map[int]LineBound{
56+ 2: {End: 8, Soft: false},
57+ },
58+ wantRows: map[int]string{2: "abcdefgh"},
59+ },
60+ {
61+ // A DECSTBM region scroll is a local mutation, not a wrap: it must
62+ // still sever at the row above the region (top-1), whose
63+ // continuation the scroll just destroyed, and at the last moved row.
64+ name: "DECSTBM region scroll severs at both edges",
65+ height: 4,
66+ input: "abcdefghij" + "\x1b[2;3r" + "\x1b[3;1H" + "\n",
67+ wantBounds: map[int]LineBound{
68+ 0: {End: 8, Soft: false},
69+ 1: {End: 0, Soft: false},
70+ },
71+ wantRows: map[int]string{0: "abcdefgh"},
72+ },
73+ {
74+ // A region exactly one row tall blanks the wrapped row in place
75+ // instead of moving it up, so no surviving grid row may be linked.
76+ // setScrollRegion rejects top >= bottom, so the only reachable
77+ // one-row region is a one-row screen.
78+ name: "wrap on a one-row screen links nothing on the grid",
79+ height: 1,
80+ input: "abcdefghij",
81+ wantBounds: map[int]LineBound{
82+ 0: {End: 2, Soft: false},
83+ },
84+ wantRows: map[int]string{0: "ij "},
85+ },
86+ }
87+ for _, tc := range cases {
88+ t.Run(tc.name, func(t *testing.T) {
89+ s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
90+ s.Write([]byte(tc.input))
91+
92+ bounds := s.LineBounds()
93+ for y, want := range tc.wantBounds {
94+ require.Equal(t, want, bounds[y], "LineBounds()[%d]", y)
95+ }
96+ for y, want := range tc.wantRows {
97+ require.Equal(t, want, lineText(s, y), "row %d text", y)
98+ }
99+ })
100+ }
101+}
102+
103+// The soft link also drives reflow, so restoring it fixes resize too: a line
104+// wrapped out of the bottom row used to be two unrelated physical rows, and
105+// widening the pane left it split instead of rejoining it.
106+func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) {
107+ s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxCells: 4096})
108+ s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij"))
109+
110+ s.Resize(16, 4)
111+
112+ require.Equal(t, "abcdefghij ", lineText(s, 2), "the wrapped line must rejoin on one row")
113+ require.Equal(t, LineBound{End: 10}, s.LineBounds()[2])
114+ require.Equal(t, " ", lineText(s, 3), "its continuation row must be consumed by the rejoin")
115+}
116+
117+// A wrap that scrolls the primary screen must hand history the same soft link
118+// it leaves on the grid, so a line wrapped at the bottom of a full pane still
119+// rejoins after it scrolls away.
120+func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) {
121+ const (
122+ width = 8
123+ height = 4
124+ )
125+ s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
126+ // Wrap out of the bottom row, then push both physical rows into history.
127+ s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n"))
128+
129+ view := s.History().SealAndView()
130+ require.Equal(t, 7, view.Len(), "history row count")
131+ require.Equal(t, "abcdefgh", rowString(view.BorrowedRow(5)))
132+ require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft")
133+ require.Equal(t, "ij ", rowString(view.BorrowedRow(6)))
134+ require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end")
135+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close