feat(copy): bound selection ranges to row content
- commit
- 9d2dea9e7f4bc85042e7bc8e2703c14a7028c452
- author
- brice · · 2026-10-05 07:14 UTC
- parent
- ce725b2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +66 -1
A internal/usecase/copy/content_selection_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | vt "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSelectionContentBoundedRanges(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | rows [][]vt.Cell | |
| 14 | + | bounds []vt.LineBound | |
| 15 | + | selection Selection | |
| 16 | + | target int | |
| 17 | + | want CellRange | |
| 18 | + | text string | |
| 19 | + | }{ | |
| 20 | + | {"line", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 0}, Granularity: Line, Enabled: true}, 0, CellRange{Row: 0, End: 1}, "ab"}, | |
| 21 | + | {"character middle", [][]vt.Cell{padRow("x", 8), padRow("ab", 8), padRow("z", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 1}, "x\nab\nz"}, | |
| 22 | + | {"character end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{End: 1}, "ab"}, | |
| 23 | + | {"soft trailing spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 7}, "abc de"}, | |
| 24 | + | {"soft partial bound", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 4}, "abc de"}, | |
| 25 | + | {"blank middle", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: -1}, "ab\n\ncd"}, | |
| 26 | + | {"anchor in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 5}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{Start: 5, End: 4}, ""}, | |
| 27 | + | } | |
| 28 | + | for _, tt := range tests { | |
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |
| 30 | + | doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "") | |
| 31 | + | got, ok := tt.selection.RangeForRow(doc, tt.target) | |
| 32 | + | require.True(t, ok) | |
| 33 | + | require.Equal(t, tt.want, got) | |
| 34 | + | require.Contains(t, tt.selection.Ranges(doc), got) | |
| 35 | + | require.Equal(t, tt.text, tt.selection.Text(doc)) | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestDocumentExtractSelectedEmptyRanges(t *testing.T) { | |
| 41 | + | doc := NewDocument(NewSnapshotFromRows([][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, 8, 3), "") | |
| 42 | + | tests := []struct { | |
| 43 | + | name string | |
| 44 | + | ranges []CellRange | |
| 45 | + | want string | |
| 46 | + | }{ | |
| 47 | + | {"empty", []CellRange{{Row: 0, End: -1}}, ""}, | |
| 48 | + | {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"}, | |
| 49 | + | } | |
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) }) | |
| 52 | + | } | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | vt "github.com/bnema/vev-vt" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestSelectionContentBoundedRanges(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | rows [][]vt.Cell | |||
| 14 | + | bounds []vt.LineBound | |||
| 15 | + | selection Selection | |||
| 16 | + | target int | |||
| 17 | + | want CellRange | |||
| 18 | + | text string | |||
| 19 | + | }{ | |||
| 20 | + | {"line", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 0}, Granularity: Line, Enabled: true}, 0, CellRange{Row: 0, End: 1}, "ab"}, | |||
| 21 | + | {"character middle", [][]vt.Cell{padRow("x", 8), padRow("ab", 8), padRow("z", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 1}, "x\nab\nz"}, | |||
| 22 | + | {"character end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{End: 1}, "ab"}, | |||
| 23 | + | {"soft trailing spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 7}, "abc de"}, | |||
| 24 | + | {"soft partial bound", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 0, CellRange{End: 4}, "abc de"}, | |||
| 25 | + | {"blank middle", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Selection{Anchor: Pos{0, 0}, Active: Pos{2, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: -1}, "ab\n\ncd"}, | |||
| 26 | + | {"anchor in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Selection{Anchor: Pos{0, 5}, Active: Pos{0, 6}, Granularity: Character, Enabled: true}, 0, CellRange{Start: 5, End: 4}, ""}, | |||
| 27 | + | } | |||
| 28 | + | for _, tt := range tests { | |||
| 29 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 30 | + | doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "") | |||
| 31 | + | got, ok := tt.selection.RangeForRow(doc, tt.target) | |||
| 32 | + | require.True(t, ok) | |||
| 33 | + | require.Equal(t, tt.want, got) | |||
| 34 | + | require.Contains(t, tt.selection.Ranges(doc), got) | |||
| 35 | + | require.Equal(t, tt.text, tt.selection.Text(doc)) | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestDocumentExtractSelectedEmptyRanges(t *testing.T) { | |||
| 41 | + | doc := NewDocument(NewSnapshotFromRows([][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, 8, 3), "") | |||
| 42 | + | tests := []struct { | |||
| 43 | + | name string | |||
| 44 | + | ranges []CellRange | |||
| 45 | + | want string | |||
| 46 | + | }{ | |||
| 47 | + | {"empty", []CellRange{{Row: 0, End: -1}}, ""}, | |||
| 48 | + | {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"}, | |||
| 49 | + | } | |||
| 50 | + | for _, tt := range tests { | |||
| 51 | + | t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
M internal/usecase/copy/document.go +4 -0
| @@ -285,6 +285,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||
| 285 | 285 | if len(row) == 0 { | |
| 286 | 286 | return "", false, r.Start == 0 && r.End == 0 | |
| 287 | 287 | } | |
| 288 | + | if r.End < r.Start { | |
| 289 | + | bound := d.snapshot.Bound(r.Row) | |
| 290 | + | return "", bound.Soft && !logicalEnd, true | |
| 291 | + | } | |
| 288 | 292 | start, end := min(r.Start, r.End), max(r.Start, r.End) | |
| 289 | 293 | if end < 0 || start >= len(row) { | |
| 290 | 294 | return "", false, false | |
| @@ -285,6 +285,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||||
| 285 | if len(row) == 0 { | 285 | if len(row) == 0 { | ||
| 286 | return "", false, r.Start == 0 && r.End == 0 | 286 | return "", false, r.Start == 0 && r.End == 0 | ||
| 287 | } | 287 | } | ||
| 288 | + | if r.End < r.Start { | |||
| 289 | + | bound := d.snapshot.Bound(r.Row) | |||
| 290 | + | return "", bound.Soft && !logicalEnd, true | |||
| 291 | + | } | |||
| 288 | start, end := min(r.Start, r.End), max(r.Start, r.End) | 292 | start, end := min(r.Start, r.End), max(r.Start, r.End) | ||
| 289 | if end < 0 || start >= len(row) { | 293 | if end < 0 || start >= len(row) { | ||
| 290 | return "", false, false | 294 | return "", false, false | ||
M internal/usecase/copy/selection.go +9 -1
| @@ -123,8 +123,12 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||
| 123 | 123 | if len(cells) == 0 { | |
| 124 | 124 | return CellRange{Row: row}, true | |
| 125 | 125 | } | |
| 126 | + | limit := doc.contentEnd(row) | |
| 127 | + | if limit == 0 { | |
| 128 | + | return CellRange{Row: row, End: -1}, true | |
| 129 | + | } | |
| 126 | 130 | if b.linewise { | |
| 127 | - | return CellRange{Row: row, End: len(cells) - 1}, true | |
| 131 | + | return CellRange{Row: row, End: limit - 1}, true | |
| 128 | 132 | } | |
| 129 | 133 | ||
| 130 | 134 | start, end := 0, len(cells)-1 | |
| @@ -134,6 +138,10 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||
| 134 | 138 | if row == b.end.Row { | |
| 135 | 139 | end = glyphEnd(cells, b.end.Col) | |
| 136 | 140 | } | |
| 141 | + | end = min(end, limit-1) | |
| 142 | + | if start > end { | |
| 143 | + | end = start - 1 | |
| 144 | + | } | |
| 137 | 145 | return CellRange{Row: row, Start: start, End: end}, true | |
| 138 | 146 | } | |
| 139 | 147 | ||
| @@ -123,8 +123,12 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||||
| 123 | if len(cells) == 0 { | 123 | if len(cells) == 0 { | ||
| 124 | return CellRange{Row: row}, true | 124 | return CellRange{Row: row}, true | ||
| 125 | } | 125 | } | ||
| 126 | + | limit := doc.contentEnd(row) | |||
| 127 | + | if limit == 0 { | |||
| 128 | + | return CellRange{Row: row, End: -1}, true | |||
| 129 | + | } | |||
| 126 | if b.linewise { | 130 | if b.linewise { | ||
| 127 | - | return CellRange{Row: row, End: len(cells) - 1}, true | 131 | + | return CellRange{Row: row, End: limit - 1}, true |
| 128 | } | 132 | } | ||
| 129 | 133 | ||||
| 130 | start, end := 0, len(cells)-1 | 134 | start, end := 0, len(cells)-1 | ||
| @@ -134,6 +138,10 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||||
| 134 | if row == b.end.Row { | 138 | if row == b.end.Row { | ||
| 135 | end = glyphEnd(cells, b.end.Col) | 139 | end = glyphEnd(cells, b.end.Col) | ||
| 136 | } | 140 | } | ||
| 141 | + | end = min(end, limit-1) | |||
| 142 | + | if start > end { | |||
| 143 | + | end = start - 1 | |||
| 144 | + | } | |||
| 137 | return CellRange{Row: row, Start: start, End: end}, true | 145 | return CellRange{Row: row, Start: start, End: end}, true | ||
| 138 | } | 146 | } | ||
| 139 | 147 | ||||