detached b33d82f

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
237237 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
238238 }
239239
240-// Extract returns inclusive ranges joined by physical-row newlines. Line-wise
241-// extraction trims trailing blank spaces; stream extraction preserves them.
242-func (d *Document) Extract(ranges []CellRange, linewise bool) string {
240+// Extract renders inclusive ranges as copyable text. Every emitted line drops
241+// the grid padding to its right, and rows the VT marked as soft-wrapped are
242+// joined only when the next emitted segment is the next physical row.
243+func (d *Document) Extract(ranges []CellRange) string {
243244 if len(ranges) == 0 {
244245 return ""
245246 }
246- lines := make([]string, 0, len(ranges))
247+
248+ // Filter invalid ranges before deciding which segment is final or adjacent.
249+ // rangeText with logicalEnd=true performs the same normalization and bounds
250+ // checks while deliberately trimming, but this validation text is discarded.
251+ emitted := make([]CellRange, 0, len(ranges))
247252 for _, r := range ranges {
248- text, ok := d.rangeText(r)
249- if !ok {
250- continue
253+ if _, _, ok := d.rangeText(r, true); ok {
254+ emitted = append(emitted, r)
251255 }
252- if linewise {
253- text = strings.TrimRight(text, " ")
256+ }
257+
258+ var out strings.Builder
259+ for i, r := range emitted {
260+ last := i == len(emitted)-1
261+ adjacent := !last && emitted[i+1].Row == r.Row+1
262+ text, soft, _ := d.rangeText(r, last || !adjacent)
263+ out.WriteString(text)
264+ if !last && !soft {
265+ out.WriteByte('\n')
254266 }
255- lines = append(lines, text)
256267 }
257- return strings.Join(lines, "\n")
268+ return out.String()
258269 }
259270
260-func (d *Document) rangeText(r CellRange) (string, bool) {
271+// rangeText renders one physical row of a selection and reports whether that
272+// row continues into the next emitted physical row. A row that continues is cut
273+// at its logical extent, keeping trailing spaces the application really printed;
274+// any logical end is cut after its last content cell, dropping grid padding.
275+func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
261276 if r.Row < 0 || r.Row >= d.Len() {
262- return "", false
277+ return "", false, false
263278 }
264279 row := d.Row(r.Row)
265280 if len(row) == 0 {
266- return "", r.Start == 0 && r.End == 0
281+ return "", false, r.Start == 0 && r.End == 0
267282 }
268283 start, end := min(r.Start, r.End), max(r.Start, r.End)
269284 if end < 0 || start >= len(row) {
270- return "", false
285+ return "", false, false
271286 }
272287 start = max(start, 0)
273288 end = min(end, len(row)-1)
274- headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start})
289+
290+ head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
275291 if !ok {
276- return "", false
292+ return "", false, false
293+ }
294+ start = head.Col
295+
296+ bound := d.snapshot.Bound(r.Row)
297+ soft := bound.Soft && !logicalEnd && end >= len(row)-1
298+ if soft {
299+ end = min(end, min(max(bound.End, 0), len(row))-1)
300+ } else {
301+ end = lastContentCol(row, start, end)
277302 }
278- headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end})
303+ if end < start {
304+ return "", soft, true
305+ }
306+
307+ tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
279308 if !ok {
280- return "", false
309+ return "", soft, true
310+ }
311+ return d.cellsText(row, start, tail.Col), soft, true
312+}
313+
314+// lastContentCol returns the rightmost column in [start, end] holding content,
315+// or start-1 when the span is blank. Continuation cells belong to the glyph on
316+// their left, so they never count on their own.
317+func lastContentCol(row []renderer.Cell, start, end int) int {
318+ for col := end; col >= start; col-- {
319+ cell := row[col]
320+ if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
321+ continue
322+ }
323+ return col
281324 }
282- return d.cellsText(row, headStart.Col, headEnd.Col), true
325+ return start - 1
283326 }
284327
285328 func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
@@ -237,49 +237,92 @@ func (d *Document) LineText(row int) string {
237 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")237 return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ")
238 }238 }
239 239
240-// Extract returns inclusive ranges joined by physical-row newlines. Line-wise240+// Extract renders inclusive ranges as copyable text. Every emitted line drops
241-// extraction trims trailing blank spaces; stream extraction preserves them.241+// the grid padding to its right, and rows the VT marked as soft-wrapped are
242-func (d *Document) Extract(ranges []CellRange, linewise bool) string {242+// joined only when the next emitted segment is the next physical row.
243+func (d *Document) Extract(ranges []CellRange) string {
243 if len(ranges) == 0 {244 if len(ranges) == 0 {
244 return ""245 return ""
245 }246 }
246- lines := make([]string, 0, len(ranges))247+
248+ // Filter invalid ranges before deciding which segment is final or adjacent.
249+ // rangeText with logicalEnd=true performs the same normalization and bounds
250+ // checks while deliberately trimming, but this validation text is discarded.
251+ emitted := make([]CellRange, 0, len(ranges))
247 for _, r := range ranges {252 for _, r := range ranges {
248- text, ok := d.rangeText(r)253+ if _, _, ok := d.rangeText(r, true); ok {
249- if !ok {254+ emitted = append(emitted, r)
250- continue
251 }255 }
252- if linewise {256+ }
253- text = strings.TrimRight(text, " ")257+
258+ var out strings.Builder
259+ for i, r := range emitted {
260+ last := i == len(emitted)-1
261+ adjacent := !last && emitted[i+1].Row == r.Row+1
262+ text, soft, _ := d.rangeText(r, last || !adjacent)
263+ out.WriteString(text)
264+ if !last && !soft {
265+ out.WriteByte('\n')
254 }266 }
255- lines = append(lines, text)
256 }267 }
257- return strings.Join(lines, "\n")268+ return out.String()
258 }269 }
259 270
260-func (d *Document) rangeText(r CellRange) (string, bool) {271+// rangeText renders one physical row of a selection and reports whether that
272+// row continues into the next emitted physical row. A row that continues is cut
273+// at its logical extent, keeping trailing spaces the application really printed;
274+// any logical end is cut after its last content cell, dropping grid padding.
275+func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) {
261 if r.Row < 0 || r.Row >= d.Len() {276 if r.Row < 0 || r.Row >= d.Len() {
262- return "", false277+ return "", false, false
263 }278 }
264 row := d.Row(r.Row)279 row := d.Row(r.Row)
265 if len(row) == 0 {280 if len(row) == 0 {
266- return "", r.Start == 0 && r.End == 0281+ return "", false, r.Start == 0 && r.End == 0
267 }282 }
268 start, end := min(r.Start, r.End), max(r.Start, r.End)283 start, end := min(r.Start, r.End), max(r.Start, r.End)
269 if end < 0 || start >= len(row) {284 if end < 0 || start >= len(row) {
270- return "", false285+ return "", false, false
271 }286 }
272 start = max(start, 0)287 start = max(start, 0)
273 end = min(end, len(row)-1)288 end = min(end, len(row)-1)
274- headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start})289+
290+ head, ok := d.Normalize(Pos{Row: r.Row, Col: start})
275 if !ok {291 if !ok {
276- return "", false292+ return "", false, false
293+ }
294+ start = head.Col
295+
296+ bound := d.snapshot.Bound(r.Row)
297+ soft := bound.Soft && !logicalEnd && end >= len(row)-1
298+ if soft {
299+ end = min(end, min(max(bound.End, 0), len(row))-1)
300+ } else {
301+ end = lastContentCol(row, start, end)
277 }302 }
278- headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end})303+ if end < start {
304+ return "", soft, true
305+ }
306+
307+ tail, ok := d.Normalize(Pos{Row: r.Row, Col: end})
279 if !ok {308 if !ok {
280- return "", false309+ return "", soft, true
310+ }
311+ return d.cellsText(row, start, tail.Col), soft, true
312+}
313+
314+// lastContentCol returns the rightmost column in [start, end] holding content,
315+// or start-1 when the span is blank. Continuation cells belong to the glyph on
316+// their left, so they never count on their own.
317+func lastContentCol(row []renderer.Cell, start, end int) int {
318+ for col := end; col >= start; col-- {
319+ cell := row[col]
320+ if cell.Continuation || cell.Rune == 0 || cell.Rune == ' ' {
321+ continue
322+ }
323+ return col
281 }324 }
282- return d.cellsText(row, headStart.Col, headEnd.Col), true325+ return start - 1
283 }326 }
284 327
285 func (d *Document) cellsText(row []renderer.Cell, start, end int) string {328 func (d *Document) cellsText(row []renderer.Cell, start, end int) string {
M internal/usecase/copy/document_test.go +134 -12
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/pkg/renderer"
7+ "github.com/bnema/vev/pkg/vt"
78 "github.com/stretchr/testify/require"
89 )
910
@@ -216,22 +217,143 @@ func TestDocumentExtractRanges(t *testing.T) {
216217 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
217218
218219 tests := []struct {
219- name string
220- ranges []CellRange
221- linewise bool
222- want string
220+ name string
221+ ranges []CellRange
222+ want string
223223 }{
224- {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"},
225- {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"},
226- {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"},
227- {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"},
228- {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""},
229- {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""},
230- {"no ranges", nil, false, ""},
224+ {"inclusive partial multiline drops each row's padding", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, "pha\nbe"},
225+ {"whole row drops trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, "alpha"},
226+ {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, "界"},
227+ {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, "界"},
228+ {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, ""},
229+ {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, ""},
230+ {"no ranges", nil, ""},
231231 }
232232 for _, tt := range tests {
233233 t.Run(tt.name, func(t *testing.T) {
234- require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise))
234+ require.Equal(t, tt.want, doc.Extract(tt.ranges))
235+ })
236+ }
237+}
238+
239+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240+ // pad renders s into a width-w row, blank-filled like a real terminal grid.
241+ pad := func(s string, w int) []renderer.Cell {
242+ cells := make([]renderer.Cell, w)
243+ for i := range cells {
244+ cells[i] = renderer.BlankCell()
245+ }
246+ for i, r := range []rune(s) {
247+ if i >= w {
248+ break
249+ }
250+ cells[i] = renderer.Cell{Rune: r}
251+ }
252+ return cells
253+ }
254+
255+ tests := []struct {
256+ name string
257+ rows [][]renderer.Cell
258+ bounds []vt.LineBound
259+ ranges []CellRange
260+ want string
261+ }{
262+ {
263+ name: "hard row drops its padding",
264+ rows: [][]renderer.Cell{pad("ab", 8)},
265+ bounds: []vt.LineBound{{End: 2}},
266+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267+ want: "ab",
268+ },
269+ {
270+ name: "soft row joins without a newline",
271+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
272+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274+ want: "abcdefghij",
275+ },
276+ {
277+ name: "soft row keeps a real trailing space",
278+ rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
279+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281+ want: "abc de",
282+ },
283+ {
284+ name: "three consecutive soft rows",
285+ rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
286+ bounds: []vt.LineBound{
287+ {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288+ },
289+ ranges: []CellRange{
290+ {Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
291+ {Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
292+ },
293+ want: "aaaabbbbccccdd",
294+ },
295+ {
296+ name: "hard rows keep their newline",
297+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
298+ bounds: []vt.LineBound{{End: 2}, {End: 2}},
299+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300+ want: "ab\ncd",
301+ },
302+ {
303+ name: "sparse ranges do not join across a row gap",
304+ rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
305+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307+ want: "ab\ncd",
308+ },
309+ {
310+ name: "invalid trailing range does not prevent final emitted row trimming",
311+ rows: [][]renderer.Cell{pad("ab", 8)},
312+ bounds: []vt.LineBound{{End: 8, Soft: true}},
313+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314+ want: "ab",
315+ },
316+ {
317+ name: "blank row survives as an empty line",
318+ rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
319+ bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321+ want: "ab\n\ncd",
322+ },
323+ {
324+ name: "the last row is trimmed even when soft",
325+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
326+ bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328+ want: "ab\ncd",
329+ },
330+ {
331+ name: "wide glyph pushed off a soft row's edge",
332+ // "a界" occupies columns 0..2; the wide rune did not fit at column 3,
333+ // so End stops at 3 and column 3 is abandoned padding.
334+ rows: [][]renderer.Cell{
335+ {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336+ pad("b", 4),
337+ },
338+ bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339+ ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
340+ want: "a界b",
341+ },
342+ {
343+ name: "selection stopping mid soft row",
344+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
345+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346+ ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347+ want: "cde",
348+ },
349+ }
350+
351+ for _, tc := range tests {
352+ t.Run(tc.name, func(t *testing.T) {
353+ doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
354+ if got := doc.Extract(tc.ranges); got != tc.want {
355+ t.Errorf("Extract() = %q, want %q", got, tc.want)
356+ }
235357 })
236358 }
237359 }
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7+ "github.com/bnema/vev/pkg/vt"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
@@ -216,22 +217,143 @@ func TestDocumentExtractRanges(t *testing.T) {
216 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")217 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
217 218
218 tests := []struct {219 tests := []struct {
219- name string220+ name string
220- ranges []CellRange221+ ranges []CellRange
221- linewise bool222+ want string
222- want string
223 }{223 }{
224- {"inclusive partial multiline preserves spaces", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, false, "pha \nbe"},224+ {"inclusive partial multiline drops each row's padding", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, "pha\nbe"},
225- {"linewise trims trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, true, "alpha"},225+ {"whole row drops trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, "alpha"},
226- {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, false, "界"},226+ {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, "界"},
227- {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, false, "界"},227+ {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, "界"},
228- {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, false, ""},228+ {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, ""},
229- {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, false, ""},229+ {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, ""},
230- {"no ranges", nil, false, ""},230+ {"no ranges", nil, ""},
231 }231 }
232 for _, tt := range tests {232 for _, tt := range tests {
233 t.Run(tt.name, func(t *testing.T) {233 t.Run(tt.name, func(t *testing.T) {
234- require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise))234+ require.Equal(t, tt.want, doc.Extract(tt.ranges))
235+ })
236+ }
237+}
238+
239+func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240+ // pad renders s into a width-w row, blank-filled like a real terminal grid.
241+ pad := func(s string, w int) []renderer.Cell {
242+ cells := make([]renderer.Cell, w)
243+ for i := range cells {
244+ cells[i] = renderer.BlankCell()
245+ }
246+ for i, r := range []rune(s) {
247+ if i >= w {
248+ break
249+ }
250+ cells[i] = renderer.Cell{Rune: r}
251+ }
252+ return cells
253+ }
254+
255+ tests := []struct {
256+ name string
257+ rows [][]renderer.Cell
258+ bounds []vt.LineBound
259+ ranges []CellRange
260+ want string
261+ }{
262+ {
263+ name: "hard row drops its padding",
264+ rows: [][]renderer.Cell{pad("ab", 8)},
265+ bounds: []vt.LineBound{{End: 2}},
266+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267+ want: "ab",
268+ },
269+ {
270+ name: "soft row joins without a newline",
271+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
272+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274+ want: "abcdefghij",
275+ },
276+ {
277+ name: "soft row keeps a real trailing space",
278+ rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
279+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281+ want: "abc de",
282+ },
283+ {
284+ name: "three consecutive soft rows",
285+ rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
286+ bounds: []vt.LineBound{
287+ {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288+ },
289+ ranges: []CellRange{
290+ {Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
291+ {Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
292+ },
293+ want: "aaaabbbbccccdd",
294+ },
295+ {
296+ name: "hard rows keep their newline",
297+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
298+ bounds: []vt.LineBound{{End: 2}, {End: 2}},
299+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300+ want: "ab\ncd",
301+ },
302+ {
303+ name: "sparse ranges do not join across a row gap",
304+ rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
305+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307+ want: "ab\ncd",
308+ },
309+ {
310+ name: "invalid trailing range does not prevent final emitted row trimming",
311+ rows: [][]renderer.Cell{pad("ab", 8)},
312+ bounds: []vt.LineBound{{End: 8, Soft: true}},
313+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314+ want: "ab",
315+ },
316+ {
317+ name: "blank row survives as an empty line",
318+ rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
319+ bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321+ want: "ab\n\ncd",
322+ },
323+ {
324+ name: "the last row is trimmed even when soft",
325+ rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
326+ bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327+ ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328+ want: "ab\ncd",
329+ },
330+ {
331+ name: "wide glyph pushed off a soft row's edge",
332+ // "a界" occupies columns 0..2; the wide rune did not fit at column 3,
333+ // so End stops at 3 and column 3 is abandoned padding.
334+ rows: [][]renderer.Cell{
335+ {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336+ pad("b", 4),
337+ },
338+ bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339+ ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
340+ want: "a界b",
341+ },
342+ {
343+ name: "selection stopping mid soft row",
344+ rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
345+ bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346+ ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347+ want: "cde",
348+ },
349+ }
350+
351+ for _, tc := range tests {
352+ t.Run(tc.name, func(t *testing.T) {
353+ doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
354+ if got := doc.Extract(tc.ranges); got != tc.want {
355+ t.Errorf("Extract() = %q, want %q", got, tc.want)
356+ }
235 })357 })
236 }358 }
237 }359 }
M internal/usecase/copy/selection.go +3 -3
@@ -137,13 +137,13 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
137137 return CellRange{Row: row, Start: start, End: end}, true
138138 }
139139
140-// Text extracts the selection using its range granularity's newline and
141-// trailing-space semantics.
140+// Text extracts the selection's ranges. The granularity is already encoded in
141+// those ranges, so extraction applies the same padding and wrap rules to all.
142142 func (s Selection) Text(doc *Document) string {
143143 if doc == nil {
144144 return ""
145145 }
146- return doc.Extract(s.Ranges(doc), s.Granularity == Line)
146+ return doc.Extract(s.Ranges(doc))
147147 }
148148
149149 // Extend moves the active endpoint. Document normalization occurs when ranges
@@ -137,13 +137,13 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) {
137 return CellRange{Row: row, Start: start, End: end}, true137 return CellRange{Row: row, Start: start, End: end}, true
138 }138 }
139 139
140-// Text extracts the selection using its range granularity's newline and140+// Text extracts the selection's ranges. The granularity is already encoded in
141-// trailing-space semantics.141+// those ranges, so extraction applies the same padding and wrap rules to all.
142 func (s Selection) Text(doc *Document) string {142 func (s Selection) Text(doc *Document) string {
143 if doc == nil {143 if doc == nil {
144 return ""144 return ""
145 }145 }
146- return doc.Extract(s.Ranges(doc), s.Granularity == Line)146+ return doc.Extract(s.Ranges(doc))
147 }147 }
148 148
149 // Extend moves the active endpoint. Document normalization occurs when ranges149 // Extend moves the active endpoint. Document normalization occurs when ranges
M internal/usecase/daemon/copymode_mouse_test.go +1 -3
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "strconv"
5- "strings"
65 "testing"
76 "time"
87
@@ -273,9 +272,8 @@ func TestCopyWordDragAndOSC52(t *testing.T) {
273272 mode := ac.overlays.copyMode
274273 selection := mode.Selection()
275274 text := mode.SelectedText()
276- width := mode.Document().Width()
277275 ac.overlays.copyMu.Unlock()
278- want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma"
276+ want := "alpha beta\ngamma"
279277 require.Equal(t, scopy.Word, selection.Granularity)
280278 require.Equal(t, want, text)
281279 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
@@ -2,7 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "strconv"4 "strconv"
5- "strings"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -273,9 +272,8 @@ func TestCopyWordDragAndOSC52(t *testing.T) {
273 mode := ac.overlays.copyMode272 mode := ac.overlays.copyMode
274 selection := mode.Selection()273 selection := mode.Selection()
275 text := mode.SelectedText()274 text := mode.SelectedText()
276- width := mode.Document().Width()
277 ac.overlays.copyMu.Unlock()275 ac.overlays.copyMu.Unlock()
278- want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma"276+ want := "alpha beta\ngamma"
279 require.Equal(t, scopy.Word, selection.Granularity)277 require.Equal(t, scopy.Word, selection.Granularity)
280 require.Equal(t, want, text)278 require.Equal(t, want, text)
281 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])279 require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0])
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -877,7 +877,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
877877 out := awaitFrame(t, sends, ports.MsgOutput)
878878 msg, err := ports.UnmarshalOutput(out.Payload)
879879 require.NoError(t, err)
880- want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07"
880+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nb")) + "\x07"
881881 require.Equal(t, want, string(msg.Data))
882882 }
883883
@@ -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) {
11151115 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
11161116 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
11171117 require.NoError(t, err)
1118- require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded))
1118+ require.Equal(t, "alpha\nb", string(decoded))
11191119 require.Nil(t, ac.overlays.copyMode)
11201120
11211121 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))

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close