Merge pull request #133 from bnema/fix/copy-mode-text-extraction
fix(copy): return selected text as formatted, not as grid
37 files · +1109 -202
M internal/usecase/copy/document.go +63 -20
| @@ -237,49 +237,92 @@ func (d *Document) LineText(row int) string { | |||
| 237 | 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-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 { | |
| 243 | 244 | if len(ranges) == 0 { | |
| 244 | 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 | 252 | 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) | |
| 251 | 255 | } | |
| 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') | |
| 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 | 276 | if r.Row < 0 || r.Row >= d.Len() { | |
| 262 | - | return "", false | |
| 277 | + | return "", false, false | |
| 263 | 278 | } | |
| 264 | 279 | row := d.Row(r.Row) | |
| 265 | 280 | if len(row) == 0 { | |
| 266 | - | return "", r.Start == 0 && r.End == 0 | |
| 281 | + | return "", false, r.Start == 0 && r.End == 0 | |
| 267 | 282 | } | |
| 268 | 283 | start, end := min(r.Start, r.End), max(r.Start, r.End) | |
| 269 | 284 | if end < 0 || start >= len(row) { | |
| 270 | - | return "", false | |
| 285 | + | return "", false, false | |
| 271 | 286 | } | |
| 272 | 287 | start = max(start, 0) | |
| 273 | 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 | 291 | 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) | |
| 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 | 308 | 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 | |
| 281 | 324 | } | |
| 282 | - | return d.cellsText(row, headStart.Col, headEnd.Col), true | |
| 325 | + | return start - 1 | |
| 283 | 326 | } | |
| 284 | 327 | ||
| 285 | 328 | 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-wise | 240 | + | // 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 "", false | 277 | + | 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 == 0 | 281 | + | 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 "", false | 285 | + | 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 "", 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) | |||
| 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 "", 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 | |||
| 281 | } | 324 | } | ||
| 282 | - | return d.cellsText(row, headStart.Col, headEnd.Col), true | 325 | + | 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 ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/bnema/vev/pkg/vt" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -216,22 +217,143 @@ func TestDocumentExtractRanges(t *testing.T) { | |||
| 216 | 217 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | |
| 217 | 218 | ||
| 218 | 219 | tests := []struct { | |
| 219 | - | name string | |
| 220 | - | ranges []CellRange | |
| 221 | - | linewise bool | |
| 222 | - | want string | |
| 220 | + | name string | |
| 221 | + | ranges []CellRange | |
| 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"}, | |
| 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, ""}, | |
| 231 | 231 | } | |
| 232 | 232 | for _, tt := range tests { | |
| 233 | 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 | } | |
| @@ -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 string | 220 | + | name string |
| 220 | - | ranges []CellRange | 221 | + | ranges []CellRange |
| 221 | - | linewise bool | 222 | + | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | const OSC52MaxPayloadBytes = 75_000 | |
| 15 | 15 | ||
| 16 | - | // Snapshot is the immutable scrollback document: sealed history plus a cloned screen. | |
| 16 | + | // Snapshot is the immutable scrollback document: sealed history plus a cloned | |
| 17 | + | // screen. screenBounds is parallel to screen rows, exactly as the history view's | |
| 18 | + | // own bounds are parallel to its chunk rows. | |
| 17 | 19 | type Snapshot struct { | |
| 18 | 20 | history vt.HistoryView | |
| 19 | 21 | screen renderer.Frame | |
| 22 | + | screenBounds []vt.LineBound | |
| 20 | 23 | Width, Height int | |
| 21 | 24 | } | |
| 22 | 25 | ||
| 23 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 26 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot { | |
| 24 | 27 | var history vt.HistoryView | |
| 25 | 28 | if historySource != nil { | |
| 26 | 29 | history = historySource.SealAndView() | |
| 27 | 30 | } | |
| 28 | - | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 31 | + | return Snapshot{ | |
| 32 | + | history: history, | |
| 33 | + | screen: screen.Clone(), | |
| 34 | + | screenBounds: append([]vt.LineBound(nil), bounds...), | |
| 35 | + | Width: screen.Width, | |
| 36 | + | Height: screen.Height, | |
| 37 | + | } | |
| 29 | 38 | } | |
| 30 | - | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 39 | + | ||
| 40 | + | // NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It | |
| 41 | + | // exists for tests; production code goes through NewSnapshot. | |
| 42 | + | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | |
| 31 | 43 | history := vt.NewHistory(vt.HistoryConfig{ | |
| 32 | - | MaxRows: len(rows), | |
| 44 | + | MaxRows: max(len(rows), 1), | |
| 33 | 45 | MaxCells: snapshotRowsCellBudget(rows), | |
| 34 | 46 | ChunkRows: 256, | |
| 35 | 47 | }) | |
| 36 | - | for _, row := range rows { | |
| 37 | - | if err := history.Append(row); err != nil { | |
| 48 | + | for i, row := range rows { | |
| 49 | + | bound := vt.LineBound{End: len(row)} | |
| 50 | + | if i < len(bounds) { | |
| 51 | + | bound = bounds[i] | |
| 52 | + | } | |
| 53 | + | if err := history.Append(row, bound); err != nil { | |
| 38 | 54 | panic("copy snapshot: configured history rejected supplied row") | |
| 39 | 55 | } | |
| 40 | 56 | } | |
| 41 | 57 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | |
| 42 | 58 | } | |
| 43 | 59 | ||
| 60 | + | // NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines. | |
| 61 | + | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 62 | + | return NewSnapshotFromLines(rows, nil, width, height) | |
| 63 | + | } | |
| 64 | + | ||
| 44 | 65 | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | |
| 45 | 66 | // Saturation is safe because a larger representable capacity does not exist. | |
| 46 | 67 | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | |
| @@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 67 | 88 | } | |
| 68 | 89 | return s.screen.Row(i) | |
| 69 | 90 | } | |
| 91 | + | ||
| 92 | + | // Bound returns the logical extent of row i, dispatching between sealed history | |
| 93 | + | // and the live screen exactly as Row does. | |
| 94 | + | func (s Snapshot) Bound(i int) vt.LineBound { | |
| 95 | + | if i < 0 { | |
| 96 | + | return vt.LineBound{} | |
| 97 | + | } | |
| 98 | + | if i < s.history.Len() { | |
| 99 | + | return s.history.Bound(i) | |
| 100 | + | } | |
| 101 | + | i -= s.history.Len() | |
| 102 | + | if i >= s.screen.Height || i >= len(s.screenBounds) { | |
| 103 | + | return vt.LineBound{} | |
| 104 | + | } | |
| 105 | + | return s.screenBounds[i] | |
| 106 | + | } | |
| 70 | 107 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |
| 71 | 108 | i := 0 | |
| 72 | 109 | stopped := false | |
| @@ -13,34 +13,55 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | const OSC52MaxPayloadBytes = 75_000 | 14 | const OSC52MaxPayloadBytes = 75_000 | ||
| 15 | 15 | ||||
| 16 | - | // Snapshot is the immutable scrollback document: sealed history plus a cloned screen. | 16 | + | // Snapshot is the immutable scrollback document: sealed history plus a cloned |
| 17 | + | // screen. screenBounds is parallel to screen rows, exactly as the history view's | |||
| 18 | + | // own bounds are parallel to its chunk rows. | |||
| 17 | type Snapshot struct { | 19 | type Snapshot struct { | ||
| 18 | history vt.HistoryView | 20 | history vt.HistoryView | ||
| 19 | screen renderer.Frame | 21 | screen renderer.Frame | ||
| 22 | + | screenBounds []vt.LineBound | |||
| 20 | Width, Height int | 23 | Width, Height int | ||
| 21 | } | 24 | } | ||
| 22 | 25 | ||||
| 23 | - | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | 26 | + | func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot { |
| 24 | var history vt.HistoryView | 27 | var history vt.HistoryView | ||
| 25 | if historySource != nil { | 28 | if historySource != nil { | ||
| 26 | history = historySource.SealAndView() | 29 | history = historySource.SealAndView() | ||
| 27 | } | 30 | } | ||
| 28 | - | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | 31 | + | return Snapshot{ |
| 32 | + | history: history, | |||
| 33 | + | screen: screen.Clone(), | |||
| 34 | + | screenBounds: append([]vt.LineBound(nil), bounds...), | |||
| 35 | + | Width: screen.Width, | |||
| 36 | + | Height: screen.Height, | |||
| 37 | + | } | |||
| 29 | } | 38 | } | ||
| 30 | - | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | 39 | + | |
| 40 | + | // NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It | |||
| 41 | + | // exists for tests; production code goes through NewSnapshot. | |||
| 42 | + | func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot { | |||
| 31 | history := vt.NewHistory(vt.HistoryConfig{ | 43 | history := vt.NewHistory(vt.HistoryConfig{ | ||
| 32 | - | MaxRows: len(rows), | 44 | + | MaxRows: max(len(rows), 1), |
| 33 | MaxCells: snapshotRowsCellBudget(rows), | 45 | MaxCells: snapshotRowsCellBudget(rows), | ||
| 34 | ChunkRows: 256, | 46 | ChunkRows: 256, | ||
| 35 | }) | 47 | }) | ||
| 36 | - | for _, row := range rows { | 48 | + | for i, row := range rows { |
| 37 | - | if err := history.Append(row); err != nil { | 49 | + | bound := vt.LineBound{End: len(row)} |
| 50 | + | if i < len(bounds) { | |||
| 51 | + | bound = bounds[i] | |||
| 52 | + | } | |||
| 53 | + | if err := history.Append(row, bound); err != nil { | |||
| 38 | panic("copy snapshot: configured history rejected supplied row") | 54 | panic("copy snapshot: configured history rejected supplied row") | ||
| 39 | } | 55 | } | ||
| 40 | } | 56 | } | ||
| 41 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | 57 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | ||
| 42 | } | 58 | } | ||
| 43 | 59 | ||||
| 60 | + | // NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines. | |||
| 61 | + | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 62 | + | return NewSnapshotFromLines(rows, nil, width, height) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 44 | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | 65 | // snapshotRowsCellBudget returns a capacity that retains every supplied row. | ||
| 45 | // Saturation is safe because a larger representable capacity does not exist. | 66 | // Saturation is safe because a larger representable capacity does not exist. | ||
| 46 | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | 67 | func snapshotRowsCellBudget(rows [][]renderer.Cell) int { | ||
| @@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||||
| 67 | } | 88 | } | ||
| 68 | return s.screen.Row(i) | 89 | return s.screen.Row(i) | ||
| 69 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | // Bound returns the logical extent of row i, dispatching between sealed history | |||
| 93 | + | // and the live screen exactly as Row does. | |||
| 94 | + | func (s Snapshot) Bound(i int) vt.LineBound { | |||
| 95 | + | if i < 0 { | |||
| 96 | + | return vt.LineBound{} | |||
| 97 | + | } | |||
| 98 | + | if i < s.history.Len() { | |||
| 99 | + | return s.history.Bound(i) | |||
| 100 | + | } | |||
| 101 | + | i -= s.history.Len() | |||
| 102 | + | if i >= s.screen.Height || i >= len(s.screenBounds) { | |||
| 103 | + | return vt.LineBound{} | |||
| 104 | + | } | |||
| 105 | + | return s.screenBounds[i] | |||
| 106 | + | } | |||
| 70 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | 107 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | ||
| 71 | i := 0 | 108 | i := 0 | ||
| 72 | stopped := false | 109 | stopped := false | ||
M internal/usecase/copy/mode_test.go +149 -3
| @@ -42,6 +42,44 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) { | |||
| 42 | 42 | } | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |
| 46 | + | row := func(s string) []renderer.Cell { | |
| 47 | + | cells := make([]renderer.Cell, 0, len(s)) | |
| 48 | + | for _, r := range s { | |
| 49 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 50 | + | } | |
| 51 | + | return cells | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 55 | + | if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil { | |
| 56 | + | t.Fatalf("append: %v", err) | |
| 57 | + | } | |
| 58 | + | screen := renderer.NewFrame(4, 2) | |
| 59 | + | copy(screen.Row(0), row("live")) | |
| 60 | + | ||
| 61 | + | snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}) | |
| 62 | + | ||
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | i int | |
| 66 | + | want vt.LineBound | |
| 67 | + | }{ | |
| 68 | + | {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}}, | |
| 69 | + | {name: "first screen row", i: 1, want: vt.LineBound{End: 4}}, | |
| 70 | + | {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}}, | |
| 71 | + | {name: "past the end", i: 3, want: vt.LineBound{}}, | |
| 72 | + | {name: "negative", i: -1, want: vt.LineBound{}}, | |
| 73 | + | } | |
| 74 | + | for _, tc := range tests { | |
| 75 | + | t.Run(tc.name, func(t *testing.T) { | |
| 76 | + | if got := snap.Bound(tc.i); got != tc.want { | |
| 77 | + | t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 45 | 83 | func TestCopyModeKeyboardSelection(t *testing.T) { | |
| 46 | 84 | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | |
| 47 | 85 | require.Equal(t, Pos{Row: 3}, m.Cursor()) | |
| @@ -189,11 +227,12 @@ func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) { | |||
| 189 | 227 | func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |
| 190 | 228 | const rows = 10_000 | |
| 191 | 229 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | |
| 230 | + | unmatched, target := row("unmatched"), row("target") | |
| 192 | 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"))) | |
| 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) | |
| 197 | 236 | view := history.SealAndView() | |
| 198 | 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 | 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 | + | } | |
| @@ -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_000 | 228 | 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) { | |||
| 137 | 137 | return CellRange{Row: row, Start: start, End: end}, true | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 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. | |
| 142 | 142 | func (s Selection) Text(doc *Document) string { | |
| 143 | 143 | if doc == nil { | |
| 144 | 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 | 149 | // 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}, true | 137 | return CellRange{Row: row, Start: start, End: end}, true | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | - | // Text extracts the selection using its range granularity's newline and | 140 | + | // 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 ranges | 149 | // 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 | |||
| 68 | 68 | geometry.pane.mu.Lock() | |
| 69 | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 70 | 70 | altScreen := geometry.pane.screen.AltScreenActive() | |
| 71 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |
| 71 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds()) | |
| 72 | 72 | geometry.pane.mu.Unlock() | |
| 73 | 73 | if mouseMode != 0 || altScreen { | |
| 74 | 74 | return false // child forwarding retains its existing raw-byte path. | |
| @@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab | |||||
| 68 | geometry.pane.mu.Lock() | 68 | geometry.pane.mu.Lock() | ||
| 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | ||
| 70 | altScreen := geometry.pane.screen.AltScreenActive() | 70 | altScreen := geometry.pane.screen.AltScreenActive() | ||
| 71 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | 71 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds()) |
| 72 | geometry.pane.mu.Unlock() | 72 | geometry.pane.mu.Unlock() | ||
| 73 | if mouseMode != 0 || altScreen { | 73 | if mouseMode != 0 || altScreen { | ||
| 74 | return false // child forwarding retains its existing raw-byte path. | 74 | return false // child forwarding retains its existing raw-byte path. | ||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 74 | 74 | return | |
| 75 | 75 | } | |
| 76 | 76 | p.mu.Lock() | |
| 77 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 77 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) | |
| 78 | 78 | p.mu.Unlock() | |
| 79 | 79 | cfg := d.currentCopyConfig() | |
| 80 | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 74 | return | 74 | return | ||
| 75 | } | 75 | } | ||
| 76 | p.mu.Lock() | 76 | p.mu.Lock() | ||
| 77 | - | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | 77 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) |
| 78 | p.mu.Unlock() | 78 | p.mu.Unlock() | ||
| 79 | cfg := d.currentCopyConfig() | 79 | cfg := d.currentCopyConfig() | ||
| 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | ||
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
| @@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 68 | 68 | sess.client = ac | |
| 69 | 69 | ac.setSession(sess) | |
| 70 | 70 | closing.mu.Lock() | |
| 71 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators) | |
| 71 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 72 | 72 | closing.mu.Unlock() | |
| 73 | 73 | ac.overlays.copyMu.Lock() | |
| 74 | 74 | ac.overlays.copyPane = closing | |
| @@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 113 | 113 | tb := sess.activeTab() | |
| 114 | 114 | p := tb.focusedPane() | |
| 115 | 115 | p.mu.Lock() | |
| 116 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 116 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 117 | 117 | p.mu.Unlock() | |
| 118 | 118 | ||
| 119 | 119 | // Pause after candidate publication, then make the captured target stale. | |
| @@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 337 | 337 | tb := sess.activeTab() | |
| 338 | 338 | p := tb.focusedPane() | |
| 339 | 339 | p.mu.Lock() | |
| 340 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 340 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) | |
| 341 | 341 | p.mu.Unlock() | |
| 342 | 342 | tc.invalidate(sess, tb, p) | |
| 343 | 343 | ||
| @@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 68 | sess.client = ac | 68 | sess.client = ac | ||
| 69 | ac.setSession(sess) | 69 | ac.setSession(sess) | ||
| 70 | closing.mu.Lock() | 70 | closing.mu.Lock() | ||
| 71 | - | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators) | 71 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators) |
| 72 | closing.mu.Unlock() | 72 | closing.mu.Unlock() | ||
| 73 | ac.overlays.copyMu.Lock() | 73 | ac.overlays.copyMu.Lock() | ||
| 74 | ac.overlays.copyPane = closing | 74 | ac.overlays.copyPane = closing | ||
| @@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 113 | tb := sess.activeTab() | 113 | tb := sess.activeTab() | ||
| 114 | p := tb.focusedPane() | 114 | p := tb.focusedPane() | ||
| 115 | p.mu.Lock() | 115 | p.mu.Lock() | ||
| 116 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | 116 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) |
| 117 | p.mu.Unlock() | 117 | p.mu.Unlock() | ||
| 118 | 118 | ||||
| 119 | // Pause after candidate publication, then make the captured target stale. | 119 | // Pause after candidate publication, then make the captured target stale. | ||
| @@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||||
| 337 | tb := sess.activeTab() | 337 | tb := sess.activeTab() | ||
| 338 | p := tb.focusedPane() | 338 | p := tb.focusedPane() | ||
| 339 | p.mu.Lock() | 339 | p.mu.Lock() | ||
| 340 | - | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | 340 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators) |
| 341 | p.mu.Unlock() | 341 | p.mu.Unlock() | ||
| 342 | tc.invalidate(sess, tb, p) | 342 | tc.invalidate(sess, tb, p) | ||
| 343 | 343 | ||||
M internal/usecase/daemon/copymode_mouse_test.go +2 -4
| @@ -2,7 +2,6 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | - | "strings" | |
| 6 | 5 | "testing" | |
| 7 | 6 | "time" | |
| 8 | 7 | ||
| @@ -273,9 +272,8 @@ func TestCopyWordDragAndOSC52(t *testing.T) { | |||
| 273 | 272 | mode := ac.overlays.copyMode | |
| 274 | 273 | selection := mode.Selection() | |
| 275 | 274 | text := mode.SelectedText() | |
| 276 | - | width := mode.Document().Width() | |
| 277 | 275 | ac.overlays.copyMu.Unlock() | |
| 278 | - | want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma" | |
| 276 | + | want := "alpha beta\ngamma" | |
| 279 | 277 | require.Equal(t, scopy.Word, selection.Granularity) | |
| 280 | 278 | require.Equal(t, want, text) | |
| 281 | 279 | require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0]) | |
| @@ -352,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |||
| 352 | 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 353 | 351 | p := sess.activeTab().focusedPane() | |
| 354 | 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 | 354 | p.mu.Unlock() | |
| 357 | 355 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 358 | 356 | }}, | |
| @@ -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.copyMode | 272 | 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) { | |||
| 112 | 112 | tb := sess.activeTab() | |
| 113 | 113 | pane := tb.focusedPane() | |
| 114 | 114 | pane.mu.Lock() | |
| 115 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | 118 | ||
| @@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 146 | 146 | tb := sess.activeTab() | |
| 147 | 147 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 148 | 148 | tb.focusedPane().screen.Write([]byte("live")) | |
| 149 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) | |
| 149 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds()) | |
| 150 | 150 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 151 | 151 | ||
| 152 | 152 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -301,7 +301,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 301 | 301 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 302 | 302 | pane := sess.tabs[0].focusedPane() | |
| 303 | 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 | 305 | copy(pane.screen.Frame.Row(0), testRow("live alpha")) | |
| 306 | 306 | ||
| 307 | 307 | d.enterCopyMode(sess, ac) | |
| @@ -330,8 +330,8 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 330 | 330 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 331 | 331 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 332 | 332 | 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 ")) | |
| 335 | 335 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 336 | 336 | ||
| 337 | 337 | d.enterCopyMode(sess, ac) | |
| @@ -434,7 +434,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 434 | 434 | } | |
| 435 | 435 | p.mu.Lock() | |
| 436 | 436 | defer p.mu.Unlock() | |
| 437 | - | return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12 | |
| 437 | + | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12 | |
| 438 | 438 | }, 2*time.Second, 5*time.Millisecond) | |
| 439 | 439 | ||
| 440 | 440 | sess := firstSession(d) | |
| @@ -505,8 +505,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 505 | 505 | p, _ := newBlockingPTY(t) | |
| 506 | 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 507 | 507 | 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 ")) | |
| 510 | 510 | sess.tabs[0].focusedPane().screen.Write([]byte("live")) | |
| 511 | 511 | ||
| 512 | 512 | d.enterCopyMode(sess, ac) | |
| @@ -739,11 +739,11 @@ func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 739 | 739 | var want *pane | |
| 740 | 740 | if tc.useFloating { | |
| 741 | 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 | 743 | want.screen.Write([]byte("flt-live")) | |
| 744 | 744 | } else { | |
| 745 | 745 | want = main | |
| 746 | - | require.NoError(t, want.history.Append(testRow("one-old"))) | |
| 746 | + | appendHistoryRow(t, want.history, testRow("one-old")) | |
| 747 | 747 | want.screen.Write([]byte("one-live")) | |
| 748 | 748 | } | |
| 749 | 749 | ||
| @@ -780,11 +780,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 780 | 780 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 781 | 781 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 782 | 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 | 785 | fp.screen.Write([]byte("live")) | |
| 786 | 786 | fp.mu.Lock() | |
| 787 | - | total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len() | |
| 787 | + | total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len() | |
| 788 | 788 | fp.mu.Unlock() | |
| 789 | 789 | ||
| 790 | 790 | d.enterCopyMode(sess, ac) | |
| @@ -806,7 +806,7 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 806 | 806 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 807 | 807 | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |
| 808 | 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 | 811 | fp.screen.Write([]byte("live")) | |
| 812 | 812 | ||
| @@ -877,7 +877,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 877 | 877 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 878 | 878 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 879 | 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 | 881 | require.Equal(t, want, string(msg.Data)) | |
| 882 | 882 | } | |
| 883 | 883 | ||
| @@ -894,7 +894,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||
| 894 | 894 | base := renderer.NewFrame(20, 8) | |
| 895 | 895 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 896 | 896 | pane.screen.Write([]byte("copy")) | |
| 897 | - | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators)) | |
| 897 | + | mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators)) | |
| 898 | 898 | if tt.selectMode { | |
| 899 | 899 | mode.ToggleLineSelection() | |
| 900 | 900 | } | |
| @@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 112 | tb := sess.activeTab() | 112 | tb := sess.activeTab() | ||
| 113 | pane := tb.focusedPane() | 113 | pane := tb.focusedPane() | ||
| 114 | pane.mu.Lock() | 114 | pane.mu.Lock() | ||
| 115 | - | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | 115 | + | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()) |
| 116 | pane.mu.Unlock() | 116 | pane.mu.Unlock() | ||
| 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 117 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 118 | 118 | ||||
| @@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 146 | tb := sess.activeTab() | 146 | tb := sess.activeTab() | ||
| 147 | tb.focusedPane().screen = vt.NewScreen(12, 3) | 147 | tb.focusedPane().screen = vt.NewScreen(12, 3) | ||
| 148 | tb.focusedPane().screen.Write([]byte("live")) | 148 | tb.focusedPane().screen.Write([]byte("live")) | ||
| 149 | - | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) | 149 | + | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds()) |
| 150 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | 150 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | ||
| 151 | 151 | ||||
| 152 | bars := barState{status: sess.statusSegments(true)} | 152 | bars := barState{status: sess.statusSegments(true)} | ||
| @@ -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() >= 12 | 437 | + | return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12 |
| 438 | }, 2*time.Second, 5*time.Millisecond) | 438 | }, 2*time.Second, 5*time.Millisecond) | ||
| 439 | 439 | ||||
| 440 | sess := firstSession(d) | 440 | sess := firstSession(d) | ||
| @@ -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 *pane | 739 | 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 = main | 745 | 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 { | |||
| 432 | 432 | 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 | 443 | func tabCount(sess *session) int { | |
| 436 | 444 | sess.mu.Lock() | |
| 437 | 445 | defer sess.mu.Unlock() | |
| @@ -432,6 +432,14 @@ func testRow(text string) []renderer.Cell { | |||||
| 432 | return cells | 432 | 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) { | |||
| 1296 | 1296 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |
| 1297 | 1297 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 1298 | 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 | 1300 | require.Nil(t, ac.overlays.copyMode) | |
| 1301 | 1301 | ||
| 1302 | 1302 | exitPaint := string(mustOutputData(t, sends)) | |
| @@ -1418,7 +1418,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi | |||
| 1418 | 1418 | ||
| 1419 | 1419 | // Output evicts five lines after Press; the Document remains immutable. | |
| 1420 | 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 | 1424 | // Motion maps the next endpoint through the press-owned Document. | |
| @@ -1953,7 +1953,7 @@ func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) | |||
| 1953 | 1953 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1954 | 1954 | pane := sess.activeTab().focusedPane() | |
| 1955 | 1955 | for range 8 { | |
| 1956 | - | require.NoError(t, pane.history.Append(testRow("history"))) | |
| 1956 | + | appendHistoryRow(t, pane.history, testRow("history")) | |
| 1957 | 1957 | } | |
| 1958 | 1958 | d.enterCopyMode(sess, ac) | |
| 1959 | 1959 | 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) { | |||
| 199 | 199 | ac := &attachedClient{} | |
| 200 | 200 | ac.initOverlays() | |
| 201 | 201 | 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) | |
| 204 | 204 | ||
| 205 | 205 | ac.overlays.copyMu.Lock() | |
| 206 | 206 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 207 | 207 | ac.overlays.copyDocument = document | |
| 208 | 208 | ac.overlays.copyMu.Unlock() | |
| 209 | 209 | ||
| 210 | - | require.NoError(t, history.Append(testRow("after"))) | |
| 210 | + | appendHistoryRow(t, history, testRow("after")) | |
| 211 | 211 | renderSnap := ac.overlays.SnapshotForRender() | |
| 212 | 212 | defer renderSnap.Unlock() | |
| 213 | 213 | 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 = document | 207 | 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) { | |||
| 643 | 643 | for column := range row { | |
| 644 | 644 | row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)} | |
| 645 | 645 | } | |
| 646 | - | if err := fixture.activePane.history.Append(row); err != nil { | |
| 647 | - | fixture.t.Fatal(err) | |
| 648 | - | } | |
| 646 | + | appendHistoryRow(fixture.t, fixture.activePane.history, row) | |
| 649 | 647 | } | |
| 650 | 648 | ||
| 651 | 649 | 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 { | |||
| 835 | 835 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 836 | 836 | p := tb.focusedPane() | |
| 837 | 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 | 840 | p.screen.Write([]byte("NOW")) | |
| 841 | 841 | 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 tb | 841 | return tb | ||
M internal/usecase/daemon/render_test.go +2 -2
| @@ -1264,7 +1264,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1264 | 1264 | } | |
| 1265 | 1265 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1266 | 1266 | p.screen.Write([]byte("ab\r\ncd")) | |
| 1267 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 1267 | + | document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()) | |
| 1268 | 1268 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1269 | 1269 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1270 | 1270 | ||
| @@ -1289,7 +1289,7 @@ func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1289 | 1289 | defer releaseFloating() | |
| 1290 | 1290 | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |
| 1291 | 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 | 1293 | fp.screen.Write([]byte("flt-live")) | |
| 1294 | 1294 | installTestFloating(sess.activeTab(), fp, true) | |
| 1295 | 1295 | ||
| @@ -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 | |||
| 56 | 56 | for col := range cells { | |
| 57 | 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 | 61 | return history.SnapshotView() | |
| 62 | 62 | } | |
| @@ -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) { | |||
| 22 | 22 | t.Run(tt.name, func(t *testing.T) { | |
| 23 | 23 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 2}) | |
| 24 | 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 | 27 | tail := history.SnapshotView().Tail() | |
| 28 | 28 | ||
| @@ -71,7 +71,7 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) { | |||
| 71 | 71 | func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |
| 72 | 72 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1}) | |
| 73 | 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 | 76 | view := history.SnapshotView() | |
| 77 | 77 | require.Equal(t, 6, view.ChunkCount()) | |
| @@ -119,7 +119,7 @@ func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) { | |||
| 119 | 119 | ||
| 120 | 120 | func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) { | |
| 121 | 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 | 123 | view := history.SnapshotView() | |
| 124 | 124 | visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot() | |
| 125 | 125 | 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 { | |||
| 132 | 132 | for i := range row { | |
| 133 | 133 | row[i] = renderer.Cell{Rune: ch} | |
| 134 | 134 | } | |
| 135 | - | require.NoError(t, history.Append(row)) | |
| 135 | + | appendHistoryRow(t, history, row) | |
| 136 | 136 | } | |
| 137 | 137 | view := history.SnapshotView() | |
| 138 | 138 | 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 | |||
| 44 | 44 | tb := newTab(pty, domain.Size{Cols: 8, Rows: 3}) | |
| 45 | 45 | p := tb.panes["pane-1"] | |
| 46 | 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 | 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 | 49 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 50 | 50 | 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 sess | 50 | return sess | ||
M internal/usecase/snapshot/codec_test.go +3 -3
| @@ -210,7 +210,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 210 | 210 | // must remain a valid v3 import payload. | |
| 211 | 211 | mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 212 | 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 | 215 | mutableTail, err := vt.MarshalHistoryTail(mutableHistory.SnapshotView()) | |
| 216 | 216 | requireNoError(t, err) | |
| @@ -343,7 +343,7 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 343 | 343 | func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |
| 344 | 344 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 256, ChunkRows: 256}) | |
| 345 | 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 | 348 | sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView()) | |
| 349 | 349 | requireNoError(t, err) | |
| @@ -406,7 +406,7 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) { | |||
| 406 | 406 | t.Helper() | |
| 407 | 407 | h := vt.NewHistory(vt.HistoryConfig{MaxRows: 128, ChunkRows: 2}) | |
| 408 | 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 | 411 | sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView()) | |
| 412 | 412 | 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) { | |||
| 28 | 28 | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | |
| 29 | 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 | 36 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | |
| 32 | 37 | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | |
| 33 | - | if partialAllocs > sealedAllocs+tailRows+3 { | |
| 38 | + | if partialAllocs > sealedAllocs+tailRows+tailHeaders { | |
| 34 | 39 | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | |
| 35 | 40 | } | |
| 36 | 41 | 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" | |||
| 7 | 7 | // reflow is bounded by the live grid. | |
| 8 | 8 | type buffer struct { | |
| 9 | 9 | frame renderer.Frame | |
| 10 | - | boundaries []lineBoundary | |
| 10 | + | boundaries []LineBound | |
| 11 | 11 | } | |
| 12 | 12 | ||
| 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 | |
| 18 | 20 | } | |
| 19 | 21 | ||
| 20 | 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 | 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 | 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 | 32 | return out | |
| 31 | 33 | } | |
| 32 | 34 | ||
| @@ -34,40 +36,50 @@ func (b *buffer) content(y, end int) { | |||
| 34 | 36 | if y < 0 || y >= len(b.boundaries) { | |
| 35 | 37 | 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 | 52 | func (b *buffer) truncate(y, end int) { | |
| 41 | 53 | if y < 0 || y >= len(b.boundaries) { | |
| 42 | 54 | 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 | 59 | // insert retains the meaningful shifted tail when insertion happens within it. | |
| 48 | 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 | 62 | 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 | 67 | func (b *buffer) hard(y int) { | |
| 56 | 68 | if y >= 0 && y < len(b.boundaries) { | |
| 57 | - | b.boundaries[y].soft = false | |
| 69 | + | b.boundaries[y].Soft = false | |
| 58 | 70 | } | |
| 59 | 71 | } | |
| 60 | 72 | ||
| 61 | 73 | func (b *buffer) soft(y int) { | |
| 62 | 74 | 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 | |
| 65 | 77 | } | |
| 66 | 78 | } | |
| 67 | 79 | ||
| 68 | 80 | func (b *buffer) continueRow(y int) { | |
| 69 | 81 | if y >= 0 && y < len(b.boundaries) { | |
| 70 | - | b.boundaries[y].soft = true | |
| 82 | + | b.boundaries[y].Soft = true | |
| 71 | 83 | } | |
| 72 | 84 | } | |
| 73 | 85 | ||
| @@ -80,10 +92,10 @@ func (b *buffer) clear(y, x0, x1 int) { | |||
| 80 | 92 | // Erasing through the right edge leaves nothing flowing onto the next | |
| 81 | 93 | // row: the logical line ends here. Keeping a stale soft link would let | |
| 82 | 94 | // reflow merge a repainted row with the unrelated row below it. | |
| 83 | - | b.boundaries[y].soft = false | |
| 95 | + | b.boundaries[y].Soft = false | |
| 84 | 96 | } | |
| 85 | 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 | 107 | b.hard(top - 1) | |
| 96 | 108 | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | |
| 97 | 109 | for y := bottom - n + 1; y <= bottom; y++ { | |
| 98 | - | b.boundaries[y] = lineBoundary{} | |
| 110 | + | b.boundaries[y] = LineBound{} | |
| 99 | 111 | } | |
| 100 | 112 | b.hard(bottom - n) | |
| 101 | 113 | } | |
| @@ -106,19 +118,19 @@ func (b *buffer) scrollDown(top, bottom, n int) { | |||
| 106 | 118 | b.hard(top - 1) | |
| 107 | 119 | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | |
| 108 | 120 | for y := top; y < top+n; y++ { | |
| 109 | - | b.boundaries[y] = lineBoundary{} | |
| 121 | + | b.boundaries[y] = LineBound{} | |
| 110 | 122 | } | |
| 111 | 123 | b.hard(bottom) | |
| 112 | 124 | } | |
| 113 | 125 | ||
| 114 | 126 | func (b *buffer) hydrate() { | |
| 115 | 127 | for y := range b.boundaries { | |
| 116 | - | if b.boundaries[y].end != 0 { | |
| 128 | + | if b.boundaries[y].End != 0 { | |
| 117 | 129 | continue | |
| 118 | 130 | } | |
| 119 | 131 | for x := b.frame.Width - 1; x >= 0; x-- { | |
| 120 | 132 | if !b.frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 121 | - | b.boundaries[y].end = x + 1 | |
| 133 | + | b.boundaries[y].End = x + 1 | |
| 122 | 134 | break | |
| 123 | 135 | } | |
| 124 | 136 | } | |
| @@ -129,7 +141,7 @@ type bufferCursor struct{ row, col int } | |||
| 129 | 141 | ||
| 130 | 142 | func (b *buffer) hasSoft() bool { | |
| 131 | 143 | for _, boundary := range b.boundaries { | |
| 132 | - | if boundary.soft { | |
| 144 | + | if boundary.Soft { | |
| 133 | 145 | return true | |
| 134 | 146 | } | |
| 135 | 147 | } | |
| @@ -138,22 +150,25 @@ func (b *buffer) hasSoft() bool { | |||
| 138 | 150 | ||
| 139 | 151 | // resizeFixed keeps hard physical lines independent. It is the common shell | |
| 140 | 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 | 154 | anchor := 0 | |
| 143 | 155 | if active != nil { | |
| 144 | 156 | anchor = active.row | |
| 145 | 157 | } | |
| 146 | 158 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |
| 147 | 159 | evicted := make([][]renderer.Cell, 0, shift) | |
| 160 | + | evictedBounds := make([]LineBound, 0, shift) | |
| 148 | 161 | for y := range shift { | |
| 149 | 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 | 166 | // Fixed-line resizes can retain the boundary backing store. In particular, | |
| 152 | 167 | // keep its capacity through a short viewport so the common shrink/grow | |
| 153 | 168 | // sequence does not add metadata allocation to every resize epoch. | |
| 154 | 169 | boundaries := b.boundaries | |
| 155 | 170 | if cap(boundaries) < height { | |
| 156 | - | boundaries = make([]lineBoundary, height) | |
| 171 | + | boundaries = make([]LineBound, height) | |
| 157 | 172 | } else { | |
| 158 | 173 | boundaries = boundaries[:height] | |
| 159 | 174 | } | |
| @@ -166,7 +181,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][ | |||
| 166 | 181 | } | |
| 167 | 182 | copy(next.frame.Row(y), b.frame.Row(sy)) | |
| 168 | 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 | 185 | repairFrameRow(next.frame, y) | |
| 171 | 186 | copied++ | |
| 172 | 187 | } | |
| @@ -178,7 +193,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][ | |||
| 178 | 193 | } | |
| 179 | 194 | } | |
| 180 | 195 | *b = next | |
| 181 | - | return evicted | |
| 196 | + | return evicted, evictedBounds | |
| 182 | 197 | } | |
| 183 | 198 | ||
| 184 | 199 | type reflowPoint struct { | |
| @@ -194,7 +209,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||
| 194 | 209 | cursors := [2]*bufferCursor{active, saved} | |
| 195 | 210 | for start := 0; start < b.frame.Height; { | |
| 196 | 211 | 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 | 213 | end++ | |
| 199 | 214 | } | |
| 200 | 215 | for i, cur := range cursors { | |
| @@ -203,11 +218,11 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||
| 203 | 218 | } | |
| 204 | 219 | offset := 0 | |
| 205 | 220 | for y := start; y < cur.row; y++ { | |
| 206 | - | offset += b.boundaries[y].end | |
| 221 | + | offset += b.boundaries[y].End | |
| 207 | 222 | } | |
| 208 | 223 | points[i] = reflowPoint{ | |
| 209 | 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 | 228 | start = end + 1 | |
| @@ -219,7 +234,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||
| 219 | 234 | // the first maps both cursors and counts rows; the second writes only the | |
| 220 | 235 | // retained viewport (and the rows genuinely evicted to history). It never | |
| 221 | 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 | 238 | if !b.hasSoft() { | |
| 224 | 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 | 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 | 252 | anchor := 0 | |
| 238 | 253 | if active != nil { | |
| 239 | 254 | anchor = points[0].row | |
| @@ -243,16 +258,18 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]rend | |||
| 243 | 258 | next := newBuffer(width, height) | |
| 244 | 259 | var evicted [][]renderer.Cell | |
| 245 | 260 | var evictedCells []renderer.Cell | |
| 261 | + | var evictedBounds []LineBound | |
| 246 | 262 | if shift > 0 { | |
| 247 | 263 | // History needs owned rows. One flat backing store replaces a temporary | |
| 248 | 264 | // allocation per evicted output row. | |
| 249 | 265 | evicted = make([][]renderer.Cell, shift) | |
| 250 | 266 | evictedCells = make([]renderer.Cell, shift*width) | |
| 267 | + | evictedBounds = make([]LineBound, shift) | |
| 251 | 268 | for y := range evicted { | |
| 252 | 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 | 273 | for i, cur := range [2]*bufferCursor{active, saved} { | |
| 257 | 274 | if cur != nil { | |
| 258 | 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 | 279 | *b = *next | |
| 263 | - | return evicted | |
| 280 | + | return evicted, evictedBounds | |
| 264 | 281 | } | |
| 265 | 282 | ||
| 266 | 283 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| 267 | 284 | // [shift, shift+dst.height). Rows before shift are written straight into the | |
| 268 | 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 | 287 | row, col := 0, 0 | |
| 271 | 288 | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 272 | 289 | if outputRow < shift { | |
| @@ -286,8 +303,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 286 | 303 | output = blankEvicted(0) | |
| 287 | 304 | } | |
| 288 | 305 | 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 | |
| 291 | 316 | } | |
| 292 | 317 | row++ | |
| 293 | 318 | col = 0 | |
| @@ -312,7 +337,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 312 | 337 | ||
| 313 | 338 | for start := 0; start < b.frame.Height; { | |
| 314 | 339 | 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 | 341 | end++ | |
| 317 | 342 | } | |
| 318 | 343 | reflow := end > start | |
| @@ -320,7 +345,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 320 | 345 | truncated := false | |
| 321 | 346 | for y := start; y <= end && !truncated; y++ { | |
| 322 | 347 | cells := b.frame.Row(y) | |
| 323 | - | limit := b.boundaries[y].end | |
| 348 | + | limit := b.boundaries[y].End | |
| 324 | 349 | for x := 0; x < limit; { | |
| 325 | 350 | cell := cells[x] | |
| 326 | 351 | 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.Frame | 9 | frame renderer.Frame | ||
| 10 | - | boundaries []lineBoundary | 10 | + | 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 a | 14 | + | // 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 int | 16 | + | // Soft reports that the row continues into the following physical row. |
| 17 | - | soft bool // the row continues into the following physical row | 17 | + | 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 out | 32 | 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 | return | 37 | 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 | return | 54 | 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 | return | 62 | 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 = false | 69 | + | 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.Width | 75 | + | b.boundaries[y].End = b.frame.Width |
| 64 | - | b.boundaries[y].soft = true | 76 | + | 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 = true | 82 | + | 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 next | 92 | // Erasing through the right edge leaves nothing flowing onto the next | ||
| 81 | // row: the logical line ends here. Keeping a stale soft link would let | 93 | // 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 = false | 95 | + | 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 | continue | 129 | 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 + 1 | 133 | + | b.boundaries[y].End = x + 1 |
| 122 | break | 134 | 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 true | 145 | 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 shell | 151 | // 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 := 0 | 154 | anchor := 0 | ||
| 143 | if active != nil { | 155 | if active != nil { | ||
| 144 | anchor = active.row | 156 | 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/grow | 167 | // 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.boundaries | 169 | 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 = next | 195 | *b = next | ||
| 181 | - | return evicted | 196 | + | 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 := start | 211 | 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 := 0 | 219 | offset := 0 | ||
| 205 | for y := start; y < cur.row; y++ { | 220 | for y := start; y < cur.row; y++ { | ||
| 206 | - | offset += b.boundaries[y].end | 221 | + | 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 + 1 | 228 | 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 the | 234 | // the first maps both cursors and counts rows; the second writes only the | ||
| 220 | // retained viewport (and the rows genuinely evicted to history). It never | 235 | // 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 := 0 | 252 | anchor := 0 | ||
| 238 | if active != nil { | 253 | if active != nil { | ||
| 239 | anchor = points[0].row | 254 | 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.Cell | 259 | var evicted [][]renderer.Cell | ||
| 245 | var evictedCells []renderer.Cell | 260 | 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 temporary | 263 | // 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 = *next | 279 | *b = *next | ||
| 263 | - | return evicted | 280 | + | return evicted, evictedBounds |
| 264 | } | 281 | } | ||
| 265 | 282 | ||||
| 266 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | 283 | // 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 the | 284 | // [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, 0 | 287 | 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 = 0 | 318 | 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 := start | 339 | 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 > start | 343 | reflow := end > start | ||
| @@ -320,7 +345,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 320 | truncated := false | 345 | 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].end | 348 | + | 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 { | |||
| 22 | 22 | // mutated by the owner of a Screen; views are safe to retain after later | |
| 23 | 23 | // appends. | |
| 24 | 24 | 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 | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| 35 | 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 | 38 | type HistoryChunk struct { | |
| 37 | - | rows [][]renderer.Cell | |
| 39 | + | rows [][]renderer.Cell | |
| 40 | + | bounds []LineBound | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | 43 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| @@ -49,10 +52,11 @@ type HistoryView struct { | |||
| 49 | 52 | // independently. Sealed chunks are shared by identity; Tail is owned by the | |
| 50 | 53 | // view and can be serialized without rotating the live tail into a chunk. | |
| 51 | 54 | 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 | |
| 56 | 60 | } | |
| 57 | 61 | ||
| 58 | 62 | func NewHistory(config HistoryConfig) *History { | |
| @@ -79,9 +83,10 @@ func defaultHistoryMaxCells(maxRows int) int { | |||
| 79 | 83 | return maxRows * 160 | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 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 { | |
| 85 | 90 | if h == nil || h.maxRows == 0 { | |
| 86 | 91 | return nil | |
| 87 | 92 | } | |
| @@ -92,6 +97,7 @@ func (h *History) Append(row []renderer.Cell) error { | |||
| 92 | 97 | // capacity is MaxInt. | |
| 93 | 98 | h.evictFor(len(row)) | |
| 94 | 99 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 100 | + | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |
| 95 | 101 | h.rows++ | |
| 96 | 102 | h.cells += len(row) | |
| 97 | 103 | if len(h.tail) == h.chunkRows { | |
| @@ -100,12 +106,31 @@ func (h *History) Append(row []renderer.Cell) error { | |||
| 100 | 106 | 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 | 127 | func (h *History) sealTail() { | |
| 104 | 128 | if len(h.tail) == 0 { | |
| 105 | 129 | 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 | 132 | h.tail = nil | |
| 133 | + | h.tailBounds = nil | |
| 109 | 134 | } | |
| 110 | 135 | ||
| 111 | 136 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |
| @@ -113,8 +138,12 @@ func (h *History) sealTail() { | |||
| 113 | 138 | // size than the current history configuration. | |
| 114 | 139 | func (h *History) normalizeTail() { | |
| 115 | 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 | 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 | 172 | } else { | |
| 144 | 173 | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 145 | 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 | 177 | continue | |
| 149 | 178 | } | |
| @@ -155,6 +184,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 155 | 184 | h.cells -= len(row) | |
| 156 | 185 | h.tail[0] = nil | |
| 157 | 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 | 206 | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 177 | 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 | 213 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | |
| 181 | 214 | } | |
| @@ -187,10 +220,11 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 187 | 220 | return HistorySnapshotView{} | |
| 188 | 221 | } | |
| 189 | 222 | 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, | |
| 194 | 228 | } | |
| 195 | 229 | } | |
| 196 | 230 | ||
| @@ -282,6 +316,24 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 282 | 316 | 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 | 337 | func (v HistorySnapshotView) Len() int { return v.rows } | |
| 286 | 338 | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 287 | 339 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |
| @@ -299,7 +351,11 @@ func (v HistorySnapshotView) Tail() HistoryView { | |||
| 299 | 351 | if len(v.tail) == 0 { | |
| 300 | 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 | 361 | 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 later | 22 | // 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 int | 25 | + | maxRows int |
| 26 | - | maxCells int | 26 | + | maxCells int |
| 27 | - | chunkRows int | 27 | + | chunkRows int |
| 28 | - | chunks []*HistoryChunk | 28 | + | chunks []*HistoryChunk |
| 29 | - | tail [][]renderer.Cell | 29 | + | tail [][]renderer.Cell |
| 30 | - | rows int | 30 | + | tailBounds []LineBound |
| 31 | - | cells int | 31 | + | rows int |
| 32 | + | cells int | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | // HistoryChunk is an immutable group of history rows. Its identity is stable | 35 | // 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.Cell | 39 | + | rows [][]renderer.Cell |
| 40 | + | bounds []LineBound | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | 43 | // 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 the | 52 | // 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 []*HistoryChunk | 55 | + | chunks []*HistoryChunk |
| 53 | - | tail [][]renderer.Cell | 56 | + | tail [][]renderer.Cell |
| 54 | - | rows int | 57 | + | tailBounds []LineBound |
| 55 | - | cells int | 58 | + | 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 * 160 | 83 | 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 nil | 91 | 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 nil | 106 | 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 | return | 129 | 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 = nil | 132 | h.tail = nil | ||
| 133 | + | h.tailBounds = nil | |||
| 109 | } | 134 | } | ||
| 110 | 135 | ||||
| 111 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | 136 | // 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: a | 173 | // 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 | continue | 177 | 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] = nil | 185 | 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 nil | 316 | 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 ( | |||
| 12 | 12 | ||
| 13 | 13 | const ( | |
| 14 | 14 | historyMagic = "VTH1" | |
| 15 | - | historyVersion = 1 | |
| 15 | + | historyVersion = 2 | |
| 16 | 16 | historyCellBytes = 41 | |
| 17 | 17 | maxHistoryChunkRows = 256 | |
| 18 | 18 | ||
| @@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 64 | 64 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 65 | 65 | } | |
| 66 | 66 | out = appendUint32(out, uint32(len(chunk.rows))) | |
| 67 | - | for _, row := range chunk.rows { | |
| 67 | + | for i, row := range chunk.rows { | |
| 68 | 68 | if uint64(len(row)) > math.MaxUint32 { | |
| 69 | 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 | 75 | out = appendUint32(out, uint32(len(row))) | |
| 72 | 76 | for _, cell := range row { | |
| 73 | 77 | if !validHistoryCell(cell) { | |
| @@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 75 | 79 | } | |
| 76 | 80 | out = appendHistoryCell(out, cell) | |
| 77 | 81 | } | |
| 82 | + | out = appendHistoryBound(out, bound) | |
| 78 | 83 | } | |
| 79 | 84 | } | |
| 80 | 85 | return out, nil | |
| @@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 135 | 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 | 163 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |
| 139 | 164 | // malformed declarations, cells, truncated data, and trailing bytes. | |
| 140 | 165 | func UnmarshalHistory(data []byte) (HistoryView, error) { | |
| @@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 181 | 206 | for range chunkCount { | |
| 182 | 207 | rowCount, ok := p.uint32() | |
| 183 | 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 | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 186 | 211 | return HistoryView{}, historyDecodeStats{}, false | |
| 187 | 212 | } | |
| 188 | 213 | ||
| 189 | 214 | var rows [][]renderer.Cell | |
| 215 | + | var bounds []LineBound | |
| 190 | 216 | if populate { | |
| 191 | 217 | rows = make([][]renderer.Cell, 0, rowCount) | |
| 218 | + | bounds = make([]LineBound, 0, rowCount) | |
| 192 | 219 | } | |
| 193 | 220 | for range rowCount { | |
| 194 | 221 | cellCount, ok := p.uint32() | |
| @@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 214 | 241 | 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 | 248 | if populate { | |
| 218 | 249 | rows = append(rows, row) | |
| 250 | + | bounds = append(bounds, bound) | |
| 219 | 251 | } | |
| 220 | 252 | } | |
| 221 | 253 | if populate { | |
| 222 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 254 | + | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds}) | |
| 223 | 255 | } | |
| 224 | 256 | } | |
| 225 | 257 | if len(p.data) != 0 { | |
| @@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 296 | 328 | }, 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 | 344 | func historyInt(raw uint64) (int, bool) { | |
| 300 | 345 | v := int64(raw) | |
| 301 | 346 | if int64(int(v)) != v { | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | const ( | 13 | const ( | ||
| 14 | historyMagic = "VTH1" | 14 | historyMagic = "VTH1" | ||
| 15 | - | historyVersion = 1 | 15 | + | historyVersion = 2 |
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | maxHistoryChunkRows = 256 | 17 | 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, nil | 85 | 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 rejects | 163 | // 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{}, false | 211 | return HistoryView{}, historyDecodeStats{}, false | ||
| 187 | } | 212 | } | ||
| 188 | 213 | ||||
| 189 | var rows [][]renderer.Cell | 214 | 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] = cell | 241 | 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 | }, true | 328 | }, 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/binary" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "math" | |
| @@ -9,6 +10,94 @@ import ( | |||
| 9 | 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 | 101 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |
| 13 | 102 | indexed := renderer.Style{ | |
| 14 | 103 | Bold: true, | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vt | 1 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { | |
| 12 | 12 | t.Helper() | |
| 13 | - | if err := history.Append(row); err != nil { | |
| 13 | + | if err := history.Append(row, LineBound{End: len(row)}); err != nil { | |
| 14 | 14 | t.Fatalf("append history row: %v", err) | |
| 15 | 15 | } | |
| 16 | 16 | } | |
| @@ -204,7 +204,7 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||
| 204 | 204 | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | |
| 205 | 205 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |
| 206 | 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 | 208 | t.Fatalf("append %q: %v", text, err) | |
| 209 | 209 | } | |
| 210 | 210 | } | |
| @@ -235,7 +235,8 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||
| 235 | 235 | ||
| 236 | 236 | for _, tt := range tests { | |
| 237 | 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 | 240 | t.Fatalf("append error = %v, want nil", err) | |
| 240 | 241 | } | |
| 241 | 242 | if got := tt.history.Len(); got != 0 { | |
| @@ -247,12 +248,14 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||
| 247 | 248 | ||
| 248 | 249 | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |
| 249 | 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 | 253 | t.Fatalf("append retained row: %v", err) | |
| 252 | 254 | } | |
| 253 | 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 | 259 | if !errors.Is(err, ErrHistoryRowTooWide) { | |
| 257 | 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 | 353 | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | |
| 287 | 354 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |
| 288 | 355 | 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 { | |||
| 111 | 111 | // configured with NewScreenWithHistory. | |
| 112 | 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 | 123 | func (s *Screen) Write(data []byte) { | |
| 115 | 124 | if len(s.escapeBuf) > 0 { | |
| 116 | 125 | 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) { | |||
| 409 | 409 | s.Frame.Cells[i] = blank | |
| 410 | 410 | } | |
| 411 | 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 | 414 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |
| 415 | 415 | default: | |
| @@ -409,7 +409,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||||
| 409 | s.Frame.Cells[i] = blank | 409 | 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) { | |||
| 25 | 25 | if saved != nil && saved.saved { | |
| 26 | 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 | 29 | if evict { | |
| 30 | - | for _, line := range evicted { | |
| 31 | - | s.recordEvicted(line) | |
| 30 | + | for i, line := range evicted { | |
| 31 | + | s.recordEvicted(line, evictedBounds[i]) | |
| 32 | 32 | } | |
| 33 | 33 | } | |
| 34 | 34 | 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 ( | |||
| 6 | 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 | 16 | if s.Frame.Height == 0 { | |
| 11 | - | return | |
| 17 | + | return false | |
| 12 | 18 | } | |
| 13 | 19 | 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) | |
| 16 | 21 | } | |
| 17 | 22 | if s.Row+1 < s.Frame.Height { | |
| 18 | 23 | s.Row++ | |
| 19 | - | return | |
| 24 | + | return false | |
| 20 | 25 | } | |
| 21 | 26 | s.Row = s.Frame.Height - 1 | |
| 27 | + | return false | |
| 22 | 28 | } | |
| 23 | 29 | ||
| 24 | 30 | func (s *Screen) nextLine() { | |
| @@ -56,13 +62,18 @@ func (s *Screen) scrollDownBy(n int) { | |||
| 56 | 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 | 71 | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |
| 61 | - | return | |
| 72 | + | return false | |
| 62 | 73 | } | |
| 63 | 74 | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 64 | 75 | if !ok { | |
| 65 | - | return | |
| 76 | + | return false | |
| 66 | 77 | } | |
| 67 | 78 | w := s.Frame.Width | |
| 68 | 79 | height := bottom - top + 1 | |
| @@ -77,6 +88,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 77 | 88 | s.buffer.scrollUp(top, bottom, n) | |
| 78 | 89 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 79 | 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 | 94 | func (s *Screen) emitLineEvicted(top, n int) { | |
| @@ -85,14 +97,16 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 85 | 97 | if s.alternate != nil || top != 0 { | |
| 86 | 98 | 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 | 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 | 108 | if s.history != nil { | |
| 95 | - | err := s.history.Append(row) | |
| 109 | + | err := s.history.Append(row, bound) | |
| 96 | 110 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |
| 97 | 111 | panic(err) | |
| 98 | 112 | } | |
| @@ -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 | - | return | 17 | + | 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 | - | return | 24 | + | return false |
| 20 | } | 25 | } | ||
| 21 | s.Row = s.Frame.Height - 1 | 26 | 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 | - | return | 72 | + | 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 | - | return | 76 | + | return false |
| 66 | } | 77 | } | ||
| 67 | w := s.Frame.Width | 78 | w := s.Frame.Width | ||
| 68 | height := bottom - top + 1 | 79 | 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 | return | 98 | 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) { | |||
| 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 | 55 | func (s *Screen) putPrintable(r rune) { | |
| 44 | 56 | w := renderer.RuneWidth(r) | |
| 45 | 57 | // Skip combining marks and zero-width characters. | |
| @@ -53,7 +65,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 53 | 65 | if s.Col >= s.Frame.Width { | |
| 54 | 66 | s.buffer.soft(s.Row) | |
| 55 | 67 | s.Col = 0 | |
| 56 | - | s.index() | |
| 68 | + | s.relinkWrap(s.index()) | |
| 57 | 69 | } | |
| 58 | 70 | if s.Row >= s.Frame.Height { | |
| 59 | 71 | s.Row = s.Frame.Height - 1 | |
| @@ -80,7 +92,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 80 | 92 | s.buffer.continueRow(s.Row) | |
| 81 | 93 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | |
| 82 | 94 | s.Col = 0 | |
| 83 | - | s.index() | |
| 95 | + | s.relinkWrap(s.index()) | |
| 84 | 96 | if s.Row >= s.Frame.Height { | |
| 85 | 97 | s.Row = s.Frame.Height - 1 | |
| 86 | 98 | } | |
| @@ -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 = 0 | 67 | 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 - 1 | 71 | 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 = 0 | 94 | 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 - 1 | 97 | s.Row = s.Frame.Height - 1 | ||
| 86 | } | 98 | } | ||
M pkg/vt/screen_write_test.go +44 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestWritePrintableAndUTF8(t *testing.T) { | |
| @@ -922,3 +923,46 @@ func TestC1Controls(t *testing.T) { | |||
| 922 | 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 | + | } | |
| @@ -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) { | |||
| 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 | 161 | func equalStrings(a, b []string) bool { | |
| 103 | 162 | if len(a) != len(b) { | |
| 104 | 163 | 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 false | 163 | return false | ||
M pkg/vt/snapshot.go +11 -10
| @@ -102,6 +102,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 102 | 102 | if len(view.chunks) == 1 { | |
| 103 | 103 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 104 | 104 | h.tail = view.chunks[0].rows | |
| 105 | + | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | |
| 105 | 106 | h.rows += len(h.tail) | |
| 106 | 107 | h.cells += view.Cells() | |
| 107 | 108 | } | |
| @@ -129,11 +130,11 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 129 | 130 | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { | |
| 130 | 131 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 131 | 132 | } | |
| 132 | - | boundaries := make([]lineBoundary, frame.Height) | |
| 133 | + | boundaries := make([]LineBound, frame.Height) | |
| 133 | 134 | for y := range boundaries { | |
| 134 | 135 | for x := frame.Width - 1; x >= 0; x-- { | |
| 135 | 136 | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 136 | - | boundaries[y].end = x + 1 | |
| 137 | + | boundaries[y].End = x + 1 | |
| 137 | 138 | break | |
| 138 | 139 | } | |
| 139 | 140 | } | |
| @@ -146,7 +147,7 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 146 | 147 | // after releasing that lock. | |
| 147 | 148 | type PrimaryVisibleSnapshot struct { | |
| 148 | 149 | frame renderer.Frame | |
| 149 | - | boundaries []lineBoundary | |
| 150 | + | boundaries []LineBound | |
| 150 | 151 | } | |
| 151 | 152 | ||
| 152 | 153 | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | |
| @@ -162,7 +163,7 @@ func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot { | |||
| 162 | 163 | } | |
| 163 | 164 | return PrimaryVisibleSnapshot{ | |
| 164 | 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 | 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 | 189 | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |
| 189 | 190 | if !ok || len(boundaries) != frame.Height { | |
| 190 | 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 | 206 | 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 { | |
| 208 | 209 | out = append(out, 1) | |
| 209 | 210 | } else { | |
| 210 | 211 | out = append(out, 0) | |
| @@ -269,7 +270,7 @@ func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |||
| 269 | 270 | 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 | 274 | if _, err := PreflightVisibleBlob(data); err != nil { | |
| 274 | 275 | return renderer.Frame{}, nil, err | |
| 275 | 276 | } | |
| @@ -283,10 +284,10 @@ func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) { | |||
| 283 | 284 | frame.Set(x, y, cell) | |
| 284 | 285 | } | |
| 285 | 286 | } | |
| 286 | - | boundaries := make([]lineBoundary, height) | |
| 287 | + | boundaries := make([]LineBound, height) | |
| 287 | 288 | offset := 13 + int(cellBytes) | |
| 288 | 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 | 291 | offset += 5 | |
| 291 | 292 | } | |
| 292 | 293 | 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].rows | 104 | 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 + 1 | 137 | + | boundaries[y].End = x + 1 |
| 137 | break | 138 | 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.Frame | 149 | frame renderer.Frame | ||
| 149 | - | boundaries []lineBoundary | 150 | + | boundaries []LineBound |
| 150 | } | 151 | } | ||
| 151 | 152 | ||||
| 152 | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | 153 | // 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, err | 270 | 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, err | 275 | 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 += 5 | 291 | offset += 5 | ||
| 291 | } | 292 | } | ||
| 292 | return frame, boundaries, nil | 293 | return frame, boundaries, nil | ||
M pkg/vt/snapshot_test.go +5 -11
| @@ -10,15 +10,9 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |
| 12 | 12 | 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")) | |
| 22 | 16 | sealed := history.View().Chunk(0) | |
| 23 | 17 | ||
| 24 | 18 | view := history.SnapshotView() | |
| @@ -58,7 +52,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||
| 58 | 52 | ||
| 59 | 53 | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail) | |
| 60 | 54 | require.NoError(t, err) | |
| 61 | - | require.NoError(t, restored.Append(historyRow("CCCC"))) | |
| 55 | + | requireHistoryAppend(t, restored, historyRow("CCCC")) | |
| 62 | 56 | ||
| 63 | 57 | require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View())) | |
| 64 | 58 | require.Len(t, restored.chunks, 1, "a full restored tail must become sealed") | |
| @@ -68,7 +62,7 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||
| 68 | 62 | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | |
| 69 | 63 | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | |
| 70 | 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 | 67 | view := history.SnapshotView() | |
| 74 | 68 | 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 | + | } | |||