fix(copy): drop grid padding and rejoin wrapped rows when copying
- commit
- b33d82f5ce8b68b395e63e8f597cf449a8018448
- author
- brice · · 2026-07-25 09:34 UTC
- parent
- 8846c59
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +203 -40
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/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/copymode_mouse_test.go +1 -3
| @@ -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]) | |
| @@ -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]) | ||
M internal/usecase/daemon/copymode_test.go +1 -1
| @@ -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 | ||
| @@ -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 | ||||
M internal/usecase/daemon/input_test.go +1 -1
| @@ -1115,7 +1115,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 1115 | 1115 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |
| 1116 | 1116 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 1117 | 1117 | require.NoError(t, err) | |
| 1118 | - | require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded)) | |
| 1118 | + | require.Equal(t, "alpha\nb", string(decoded)) | |
| 1119 | 1119 | require.Nil(t, ac.overlays.copyMode) | |
| 1120 | 1120 | ||
| 1121 | 1121 | exitPaint := string(mustOutputData(t, sends)) | |
| @@ -1115,7 +1115,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||||
| 1115 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | 1115 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | ||
| 1116 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | 1116 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | ||
| 1117 | require.NoError(t, err) | 1117 | require.NoError(t, err) | ||
| 1118 | - | require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded)) | 1118 | + | require.Equal(t, "alpha\nb", string(decoded)) |
| 1119 | require.Nil(t, ac.overlays.copyMode) | 1119 | require.Nil(t, ac.overlays.copyMode) | ||
| 1120 | 1120 | ||||
| 1121 | exitPaint := string(mustOutputData(t, sends)) | 1121 | exitPaint := string(mustOutputData(t, sends)) | ||