Merge pull request 'feat(copy): bound selection to content and preserve colors' (#5) from feat/copy-selection-content-bounds into main
Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/5
7 files · +294 -34
A internal/usecase/copy/content_render_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRenderRowsSelectionHighlightsContentOnly(t *testing.T) { | |
| 12 | + | wide := padRow("", 8) | |
| 13 | + | wide[0] = vt.Cell{Rune: 'a'} | |
| 14 | + | wide[1] = vt.Cell{Rune: '界'} | |
| 15 | + | wide[2] = vt.Cell{Continuation: true} | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | rows [][]vt.Cell | |
| 19 | + | bounds []vt.LineBound | |
| 20 | + | end Pos | |
| 21 | + | granularity Granularity | |
| 22 | + | want [][]int | |
| 23 | + | }{ | |
| 24 | + | {"hard rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Character, [][]int{{0, 1, 2}, {0, 1}}}, | |
| 25 | + | {"word rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Word, [][]int{{0, 1, 2}, {0, 1}}}, | |
| 26 | + | {"line mode", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Pos{2, 0}, Line, [][]int{{0, 1, 2}, {}, {0, 1, 2}}}, | |
| 27 | + | {"soft spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0, 1}}}, | |
| 28 | + | {"soft partial", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4}, {0, 1}}}, | |
| 29 | + | {"full hard row", [][]vt.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, nil, Pos{1, 0}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0}}}, | |
| 30 | + | {"wide glyph", [][]vt.Cell{wide}, nil, Pos{0, 0}, Line, [][]int{{0, 1, 2, 3}}}, | |
| 31 | + | {"end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Pos{0, 6}, Character, [][]int{{0, 1}}}, | |
| 32 | + | } | |
| 33 | + | for _, tt := range tests { | |
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |
| 35 | + | doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "") | |
| 36 | + | m := NewMode(doc) | |
| 37 | + | m.selection = Selection{Anchor: Pos{0, 0}, Active: tt.end, Granularity: tt.granularity, Enabled: true} | |
| 38 | + | m.navigator.Pos = Pos{0, 0} | |
| 39 | + | f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}}) | |
| 40 | + | var copied strings.Builder | |
| 41 | + | bounds, _ := m.selection.bounds(doc) | |
| 42 | + | for y, source := range tt.rows { | |
| 43 | + | cols := []int{} | |
| 44 | + | marker, hasMarker := bounds.newlineMarker(doc, y) | |
| 45 | + | for x, cell := range source { | |
| 46 | + | if f.At(x, y).Style.Inverse == cell.Style.Inverse { | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | cols = append(cols, x) | |
| 50 | + | if (hasMarker && x == marker) || cell.Continuation { | |
| 51 | + | continue | |
| 52 | + | } | |
| 53 | + | if cell.Rune == 0 { | |
| 54 | + | copied.WriteByte(' ') | |
| 55 | + | } else { | |
| 56 | + | copied.WriteRune(cell.Rune) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | require.Equal(t, tt.want[y], cols) | |
| 60 | + | if y < len(tt.rows)-1 && !doc.snapshot.Bound(y).Soft { | |
| 61 | + | copied.WriteByte('\n') | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | require.Equal(t, copied.String(), m.SelectedText(), "highlighted glyphs match copied text") | |
| 65 | + | for y, source := range tt.rows { | |
| 66 | + | require.Equal(t, source, doc.Row(y)) | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestRenderRowsSelectionAfterHistoryResize(t *testing.T) { | |
| 73 | + | screen := vt.NewScreenWithHistory(12, 2, vt.HistoryConfig{MaxRows: 64}) | |
| 74 | + | screen.Write([]byte("abc xyz\r\nq\r\nr\r\ns")) | |
| 75 | + | screen.Resize(8, 2) | |
| 76 | + | doc := NewDocument(NewSnapshot(screen.History(), screen, screen.LineBounds(), nil), "") | |
| 77 | + | m := NewMode(doc) | |
| 78 | + | m.navigator.Pos = Pos{0, 0} | |
| 79 | + | m.ViewportTop = 0 | |
| 80 | + | m.selection = Selection{Anchor: Pos{0, 0}, Active: Pos{1, 0}, Granularity: Character, Enabled: true} | |
| 81 | + | require.Equal(t, "abc xyz\nq", m.SelectedText()) | |
| 82 | + | for _, col := range []int{0, 10} { | |
| 83 | + | m.navigator.Pos = Pos{0, col} | |
| 84 | + | f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true}) | |
| 85 | + | for x := 0; x < 8; x++ { | |
| 86 | + | require.True(t, f.At(x, 0).Style.Inverse, "cursor %d, visible content column %d", col, x) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestRenderRowsSelectionPreservesCellColors(t *testing.T) { | |
| 92 | + | styles := []vt.Style{ | |
| 93 | + | {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true}, | |
| 94 | + | {Inverse: true, HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 20}, Italic: true}, | |
| 95 | + | } | |
| 96 | + | cells := []vt.Cell{{Rune: 'a', Style: styles[0]}, {Rune: 'b', Style: styles[1]}} | |
| 97 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]vt.Cell{cells}, 2, 1), "")) | |
| 98 | + | require.True(t, m.StartCharacterSelection(Pos{0, 0})) | |
| 99 | + | require.True(t, m.ExtendCharacterSelection(Pos{0, 1})) | |
| 100 | + | f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}}) | |
| 101 | + | for x, style := range styles { | |
| 102 | + | style.Inverse = !style.Inverse | |
| 103 | + | require.Equal(t, style, f.At(x, 0).Style) | |
| 104 | + | } | |
| 105 | + | require.Equal(t, cells, m.Document().Row(0)) | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRenderRowsSelectionHighlightsContentOnly(t *testing.T) { | |||
| 12 | + | wide := padRow("", 8) | |||
| 13 | + | wide[0] = vt.Cell{Rune: 'a'} | |||
| 14 | + | wide[1] = vt.Cell{Rune: '界'} | |||
| 15 | + | wide[2] = vt.Cell{Continuation: true} | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | rows [][]vt.Cell | |||
| 19 | + | bounds []vt.LineBound | |||
| 20 | + | end Pos | |||
| 21 | + | granularity Granularity | |||
| 22 | + | want [][]int | |||
| 23 | + | }{ | |||
| 24 | + | {"hard rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Character, [][]int{{0, 1, 2}, {0, 1}}}, | |||
| 25 | + | {"word rows", [][]vt.Cell{padRow("ab", 8), padRow("cd", 8)}, nil, Pos{1, 1}, Word, [][]int{{0, 1, 2}, {0, 1}}}, | |||
| 26 | + | {"line mode", [][]vt.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, nil, Pos{2, 0}, Line, [][]int{{0, 1, 2}, {}, {0, 1, 2}}}, | |||
| 27 | + | {"soft spaces", [][]vt.Cell{padRow("abc ", 8), padRow("de", 8)}, []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0, 1}}}, | |||
| 28 | + | {"soft partial", [][]vt.Cell{padRow("abc", 8), padRow("de", 8)}, []vt.LineBound{{End: 5, Soft: true}, {End: 2}}, Pos{1, 1}, Character, [][]int{{0, 1, 2, 3, 4}, {0, 1}}}, | |||
| 29 | + | {"full hard row", [][]vt.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, nil, Pos{1, 0}, Character, [][]int{{0, 1, 2, 3, 4, 5, 6, 7}, {0}}}, | |||
| 30 | + | {"wide glyph", [][]vt.Cell{wide}, nil, Pos{0, 0}, Line, [][]int{{0, 1, 2, 3}}}, | |||
| 31 | + | {"end in padding", [][]vt.Cell{padRow("ab", 8)}, nil, Pos{0, 6}, Character, [][]int{{0, 1}}}, | |||
| 32 | + | } | |||
| 33 | + | for _, tt := range tests { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | doc := NewDocument(NewSnapshotFromLines(tt.rows, tt.bounds, 8, len(tt.rows)), "") | |||
| 36 | + | m := NewMode(doc) | |||
| 37 | + | m.selection = Selection{Anchor: Pos{0, 0}, Active: tt.end, Granularity: tt.granularity, Enabled: true} | |||
| 38 | + | m.navigator.Pos = Pos{0, 0} | |||
| 39 | + | f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}}) | |||
| 40 | + | var copied strings.Builder | |||
| 41 | + | bounds, _ := m.selection.bounds(doc) | |||
| 42 | + | for y, source := range tt.rows { | |||
| 43 | + | cols := []int{} | |||
| 44 | + | marker, hasMarker := bounds.newlineMarker(doc, y) | |||
| 45 | + | for x, cell := range source { | |||
| 46 | + | if f.At(x, y).Style.Inverse == cell.Style.Inverse { | |||
| 47 | + | continue | |||
| 48 | + | } | |||
| 49 | + | cols = append(cols, x) | |||
| 50 | + | if (hasMarker && x == marker) || cell.Continuation { | |||
| 51 | + | continue | |||
| 52 | + | } | |||
| 53 | + | if cell.Rune == 0 { | |||
| 54 | + | copied.WriteByte(' ') | |||
| 55 | + | } else { | |||
| 56 | + | copied.WriteRune(cell.Rune) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | require.Equal(t, tt.want[y], cols) | |||
| 60 | + | if y < len(tt.rows)-1 && !doc.snapshot.Bound(y).Soft { | |||
| 61 | + | copied.WriteByte('\n') | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | require.Equal(t, copied.String(), m.SelectedText(), "highlighted glyphs match copied text") | |||
| 65 | + | for y, source := range tt.rows { | |||
| 66 | + | require.Equal(t, source, doc.Row(y)) | |||
| 67 | + | } | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestRenderRowsSelectionAfterHistoryResize(t *testing.T) { | |||
| 73 | + | screen := vt.NewScreenWithHistory(12, 2, vt.HistoryConfig{MaxRows: 64}) | |||
| 74 | + | screen.Write([]byte("abc xyz\r\nq\r\nr\r\ns")) | |||
| 75 | + | screen.Resize(8, 2) | |||
| 76 | + | doc := NewDocument(NewSnapshot(screen.History(), screen, screen.LineBounds(), nil), "") | |||
| 77 | + | m := NewMode(doc) | |||
| 78 | + | m.navigator.Pos = Pos{0, 0} | |||
| 79 | + | m.ViewportTop = 0 | |||
| 80 | + | m.selection = Selection{Anchor: Pos{0, 0}, Active: Pos{1, 0}, Granularity: Character, Enabled: true} | |||
| 81 | + | require.Equal(t, "abc xyz\nq", m.SelectedText()) | |||
| 82 | + | for _, col := range []int{0, 10} { | |||
| 83 | + | m.navigator.Pos = Pos{0, col} | |||
| 84 | + | f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true}) | |||
| 85 | + | for x := 0; x < 8; x++ { | |||
| 86 | + | require.True(t, f.At(x, 0).Style.Inverse, "cursor %d, visible content column %d", col, x) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestRenderRowsSelectionPreservesCellColors(t *testing.T) { | |||
| 92 | + | styles := []vt.Style{ | |||
| 93 | + | {HasForegroundRGB: true, ForegroundRGB: vt.RGB{G: 255, B: 255}, Bold: true}, | |||
| 94 | + | {Inverse: true, HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 20}, Italic: true}, | |||
| 95 | + | } | |||
| 96 | + | cells := []vt.Cell{{Rune: 'a', Style: styles[0]}, {Rune: 'b', Style: styles[1]}} | |||
| 97 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]vt.Cell{cells}, 2, 1), "")) | |||
| 98 | + | require.True(t, m.StartCharacterSelection(Pos{0, 0})) | |||
| 99 | + | require.True(t, m.ExtendCharacterSelection(Pos{0, 1})) | |||
| 100 | + | f := m.Render(vt.DefaultStyle(), vt.Style{HasBackgroundRGB: true, BackgroundRGB: vt.RGB{R: 1}}) | |||
| 101 | + | for x, style := range styles { | |||
| 102 | + | style.Inverse = !style.Inverse | |||
| 103 | + | require.Equal(t, style, f.At(x, 0).Style) | |||
| 104 | + | } | |||
| 105 | + | require.Equal(t, cells, m.Document().Row(0)) | |||
| 106 | + | } | |||
A internal/usecase/copy/content_selection_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 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 | + | {"reversed", []CellRange{{Row: 2, Start: 7, End: 0}}, "cd"}, | |
| 49 | + | {"past end", []CellRange{{Row: 0, Start: 99, End: 50}, {Row: 2, End: 1}}, "cd"}, | |
| 50 | + | {"negative", []CellRange{{Row: 0, Start: -3, End: -9}, {Row: 2, End: 1}}, "cd"}, | |
| 51 | + | {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"}, | |
| 52 | + | } | |
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) }) | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 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 | + | {"reversed", []CellRange{{Row: 2, Start: 7, End: 0}}, "cd"}, | |||
| 49 | + | {"past end", []CellRange{{Row: 0, Start: 99, End: 50}, {Row: 2, End: 1}}, "cd"}, | |||
| 50 | + | {"negative", []CellRange{{Row: 0, Start: -3, End: -9}, {Row: 2, End: 1}}, "cd"}, | |||
| 51 | + | {"blank middle", []CellRange{{Row: 0, End: 1}, {Row: 1, End: -1}, {Row: 2, End: 1}}, "ab\n\ncd"}, | |||
| 52 | + | } | |||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, doc.Extract(tt.ranges)) }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/usecase/copy/document.go +29 -2
| @@ -16,6 +16,8 @@ type Pos struct { | |||
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | // CellRange is an inclusive display-cell range on one physical document row. | |
| 19 | + | // End == Start-1 with an in-bounds Start denotes a selected row with no cells; | |
| 20 | + | // this adjacent pair is never interpreted as a reversed range. | |
| 19 | 21 | type CellRange struct { | |
| 20 | 22 | Row int | |
| 21 | 23 | Start int | |
| @@ -285,6 +287,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||
| 285 | 287 | if len(row) == 0 { | |
| 286 | 288 | return "", false, r.Start == 0 && r.End == 0 | |
| 287 | 289 | } | |
| 290 | + | if r.End == r.Start-1 && r.Start >= 0 && r.Start <= len(row) { | |
| 291 | + | bound := d.snapshot.Bound(r.Row) | |
| 292 | + | return "", bound.Soft && !logicalEnd, true | |
| 293 | + | } | |
| 288 | 294 | start, end := min(r.Start, r.End), max(r.Start, r.End) | |
| 289 | 295 | if end < 0 || start >= len(row) { | |
| 290 | 296 | return "", false, false | |
| @@ -299,9 +305,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||
| 299 | 305 | start = head.Col | |
| 300 | 306 | ||
| 301 | 307 | bound := d.snapshot.Bound(r.Row) | |
| 302 | - | soft := bound.Soft && !logicalEnd && end >= len(row)-1 | |
| 308 | + | limit := contentEndOf(row, bound) | |
| 309 | + | soft := bound.Soft && !logicalEnd && end >= limit-1 | |
| 303 | 310 | if soft { | |
| 304 | - | end = min(end, min(max(bound.End, 0), len(row))-1) | |
| 311 | + | end = min(end, limit-1) | |
| 305 | 312 | } else { | |
| 306 | 313 | end = lastContentCol(row, start, end) | |
| 307 | 314 | } | |
| @@ -316,6 +323,26 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||
| 316 | 323 | return d.cellsText(row, start, tail.Col), soft, true | |
| 317 | 324 | } | |
| 318 | 325 | ||
| 326 | + | // contentEnd returns the exclusive display-cell end of row's copyable content. | |
| 327 | + | // Soft rows end at their logical bound; hard rows drop blank padding. | |
| 328 | + | func (d *Document) contentEnd(row int) int { | |
| 329 | + | return contentEndOf(d.Row(row), d.snapshot.Bound(row)) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func contentEndOf(cells []renderer.Cell, bound vt.LineBound) int { | |
| 333 | + | if len(cells) == 0 { | |
| 334 | + | return 0 | |
| 335 | + | } | |
| 336 | + | if bound.Soft { | |
| 337 | + | return min(max(bound.End, 0), len(cells)) | |
| 338 | + | } | |
| 339 | + | last := lastContentCol(cells, 0, len(cells)-1) | |
| 340 | + | if last < 0 { | |
| 341 | + | return 0 | |
| 342 | + | } | |
| 343 | + | return glyphEnd(cells, last) + 1 | |
| 344 | + | } | |
| 345 | + | ||
| 319 | 346 | // lastContentCol returns the rightmost column in [start, end] holding content, | |
| 320 | 347 | // or start-1 when the span is blank. Continuation cells belong to the glyph on | |
| 321 | 348 | // their left, so they never count on their own. | |
| @@ -16,6 +16,8 @@ type Pos struct { | |||||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | // CellRange is an inclusive display-cell range on one physical document row. | 18 | // CellRange is an inclusive display-cell range on one physical document row. | ||
| 19 | + | // End == Start-1 with an in-bounds Start denotes a selected row with no cells; | |||
| 20 | + | // this adjacent pair is never interpreted as a reversed range. | |||
| 19 | type CellRange struct { | 21 | type CellRange struct { | ||
| 20 | Row int | 22 | Row int | ||
| 21 | Start int | 23 | Start int | ||
| @@ -285,6 +287,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||||
| 285 | if len(row) == 0 { | 287 | if len(row) == 0 { | ||
| 286 | return "", false, r.Start == 0 && r.End == 0 | 288 | return "", false, r.Start == 0 && r.End == 0 | ||
| 287 | } | 289 | } | ||
| 290 | + | if r.End == r.Start-1 && r.Start >= 0 && r.Start <= len(row) { | |||
| 291 | + | bound := d.snapshot.Bound(r.Row) | |||
| 292 | + | return "", bound.Soft && !logicalEnd, true | |||
| 293 | + | } | |||
| 288 | start, end := min(r.Start, r.End), max(r.Start, r.End) | 294 | start, end := min(r.Start, r.End), max(r.Start, r.End) | ||
| 289 | if end < 0 || start >= len(row) { | 295 | if end < 0 || start >= len(row) { | ||
| 290 | return "", false, false | 296 | return "", false, false | ||
| @@ -299,9 +305,10 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||||
| 299 | start = head.Col | 305 | start = head.Col | ||
| 300 | 306 | ||||
| 301 | bound := d.snapshot.Bound(r.Row) | 307 | bound := d.snapshot.Bound(r.Row) | ||
| 302 | - | soft := bound.Soft && !logicalEnd && end >= len(row)-1 | 308 | + | limit := contentEndOf(row, bound) |
| 309 | + | soft := bound.Soft && !logicalEnd && end >= limit-1 | |||
| 303 | if soft { | 310 | if soft { | ||
| 304 | - | end = min(end, min(max(bound.End, 0), len(row))-1) | 311 | + | end = min(end, limit-1) |
| 305 | } else { | 312 | } else { | ||
| 306 | end = lastContentCol(row, start, end) | 313 | end = lastContentCol(row, start, end) | ||
| 307 | } | 314 | } | ||
| @@ -316,6 +323,26 @@ func (d *Document) rangeText(r CellRange, logicalEnd bool) (string, bool, bool) | |||||
| 316 | return d.cellsText(row, start, tail.Col), soft, true | 323 | return d.cellsText(row, start, tail.Col), soft, true | ||
| 317 | } | 324 | } | ||
| 318 | 325 | ||||
| 326 | + | // contentEnd returns the exclusive display-cell end of row's copyable content. | |||
| 327 | + | // Soft rows end at their logical bound; hard rows drop blank padding. | |||
| 328 | + | func (d *Document) contentEnd(row int) int { | |||
| 329 | + | return contentEndOf(d.Row(row), d.snapshot.Bound(row)) | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func contentEndOf(cells []renderer.Cell, bound vt.LineBound) int { | |||
| 333 | + | if len(cells) == 0 { | |||
| 334 | + | return 0 | |||
| 335 | + | } | |||
| 336 | + | if bound.Soft { | |||
| 337 | + | return min(max(bound.End, 0), len(cells)) | |||
| 338 | + | } | |||
| 339 | + | last := lastContentCol(cells, 0, len(cells)-1) | |||
| 340 | + | if last < 0 { | |||
| 341 | + | return 0 | |||
| 342 | + | } | |||
| 343 | + | return glyphEnd(cells, last) + 1 | |||
| 344 | + | } | |||
| 345 | + | ||||
| 319 | // lastContentCol returns the rightmost column in [start, end] holding content, | 346 | // lastContentCol returns the rightmost column in [start, end] holding content, | ||
| 320 | // or start-1 when the span is blank. Continuation cells belong to the glyph on | 347 | // or start-1 when the span is blank. Continuation cells belong to the glyph on | ||
| 321 | // their left, so they never count on their own. | 348 | // their left, so they never count on their own. | ||
M internal/usecase/copy/document_test.go +48 -24
| @@ -236,21 +236,45 @@ func TestDocumentExtractRanges(t *testing.T) { | |||
| 236 | 236 | } | |
| 237 | 237 | } | |
| 238 | 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} | |
| 239 | + | // padRow blank-fills a terminal row before adding text. | |
| 240 | + | func padRow(s string, w int) []renderer.Cell { | |
| 241 | + | cells := make([]renderer.Cell, w) | |
| 242 | + | for i := range cells { | |
| 243 | + | cells[i] = renderer.BlankCell() | |
| 244 | + | } | |
| 245 | + | for i, r := range []rune(s) { | |
| 246 | + | if i >= w { | |
| 247 | + | break | |
| 251 | 248 | } | |
| 252 | - | return cells | |
| 249 | + | cells[i] = renderer.Cell{Rune: r} | |
| 253 | 250 | } | |
| 251 | + | return cells | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestDocumentContentEnd(t *testing.T) { | |
| 255 | + | tests := []struct { | |
| 256 | + | name string | |
| 257 | + | cells []renderer.Cell | |
| 258 | + | bounds []vt.LineBound | |
| 259 | + | want int | |
| 260 | + | }{ | |
| 261 | + | {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2}, | |
| 262 | + | {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3}, | |
| 263 | + | {"blank", padRow("", 8), []vt.LineBound{{}}, 0}, | |
| 264 | + | {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8}, | |
| 265 | + | {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5}, | |
| 266 | + | {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3}, | |
| 267 | + | {"nil bounds", padRow("ab", 8), nil, 2}, | |
| 268 | + | } | |
| 269 | + | for _, tt := range tests { | |
| 270 | + | t.Run(tt.name, func(t *testing.T) { | |
| 271 | + | doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "") | |
| 272 | + | require.Equal(t, tt.want, doc.contentEnd(0)) | |
| 273 | + | }) | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |
| 254 | 278 | ||
| 255 | 279 | tests := []struct { | |
| 256 | 280 | name string | |
| @@ -261,28 +285,28 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||
| 261 | 285 | }{ | |
| 262 | 286 | { | |
| 263 | 287 | name: "hard row drops its padding", | |
| 264 | - | rows: [][]renderer.Cell{pad("ab", 8)}, | |
| 288 | + | rows: [][]renderer.Cell{padRow("ab", 8)}, | |
| 265 | 289 | bounds: []vt.LineBound{{End: 2}}, | |
| 266 | 290 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}}, | |
| 267 | 291 | want: "ab", | |
| 268 | 292 | }, | |
| 269 | 293 | { | |
| 270 | 294 | name: "soft row joins without a newline", | |
| 271 | - | rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)}, | |
| 295 | + | rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, | |
| 272 | 296 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | |
| 273 | 297 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | |
| 274 | 298 | want: "abcdefghij", | |
| 275 | 299 | }, | |
| 276 | 300 | { | |
| 277 | 301 | name: "soft row keeps a real trailing space", | |
| 278 | - | rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)}, | |
| 302 | + | rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)}, | |
| 279 | 303 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | |
| 280 | 304 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | |
| 281 | 305 | want: "abc de", | |
| 282 | 306 | }, | |
| 283 | 307 | { | |
| 284 | 308 | name: "three consecutive soft rows", | |
| 285 | - | rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)}, | |
| 309 | + | rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)}, | |
| 286 | 310 | bounds: []vt.LineBound{ | |
| 287 | 311 | {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2}, | |
| 288 | 312 | }, | |
| @@ -294,35 +318,35 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||
| 294 | 318 | }, | |
| 295 | 319 | { | |
| 296 | 320 | name: "hard rows keep their newline", | |
| 297 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)}, | |
| 321 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)}, | |
| 298 | 322 | bounds: []vt.LineBound{{End: 2}, {End: 2}}, | |
| 299 | 323 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | |
| 300 | 324 | want: "ab\ncd", | |
| 301 | 325 | }, | |
| 302 | 326 | { | |
| 303 | 327 | name: "sparse ranges do not join across a row gap", | |
| 304 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)}, | |
| 328 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)}, | |
| 305 | 329 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}}, | |
| 306 | 330 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}}, | |
| 307 | 331 | want: "ab\ncd", | |
| 308 | 332 | }, | |
| 309 | 333 | { | |
| 310 | 334 | name: "invalid trailing range does not prevent final emitted row trimming", | |
| 311 | - | rows: [][]renderer.Cell{pad("ab", 8)}, | |
| 335 | + | rows: [][]renderer.Cell{padRow("ab", 8)}, | |
| 312 | 336 | bounds: []vt.LineBound{{End: 8, Soft: true}}, | |
| 313 | 337 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}}, | |
| 314 | 338 | want: "ab", | |
| 315 | 339 | }, | |
| 316 | 340 | { | |
| 317 | 341 | name: "blank row survives as an empty line", | |
| 318 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)}, | |
| 342 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, | |
| 319 | 343 | bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}}, | |
| 320 | 344 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}}, | |
| 321 | 345 | want: "ab\n\ncd", | |
| 322 | 346 | }, | |
| 323 | 347 | { | |
| 324 | 348 | name: "the last row is trimmed even when soft", | |
| 325 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)}, | |
| 349 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)}, | |
| 326 | 350 | bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}}, | |
| 327 | 351 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | |
| 328 | 352 | want: "ab\ncd", | |
| @@ -333,7 +357,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||
| 333 | 357 | // so End stops at 3 and column 3 is abandoned padding. | |
| 334 | 358 | rows: [][]renderer.Cell{ | |
| 335 | 359 | {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()}, | |
| 336 | - | pad("b", 4), | |
| 360 | + | padRow("b", 4), | |
| 337 | 361 | }, | |
| 338 | 362 | bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}}, | |
| 339 | 363 | ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}}, | |
| @@ -341,7 +365,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||
| 341 | 365 | }, | |
| 342 | 366 | { | |
| 343 | 367 | name: "selection stopping mid soft row", | |
| 344 | - | rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)}, | |
| 368 | + | rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, | |
| 345 | 369 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | |
| 346 | 370 | ranges: []CellRange{{Row: 0, Start: 2, End: 4}}, | |
| 347 | 371 | want: "cde", | |
| @@ -236,21 +236,45 @@ func TestDocumentExtractRanges(t *testing.T) { | |||||
| 236 | } | 236 | } | ||
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| 239 | - | func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | 239 | + | // padRow blank-fills a terminal row before adding text. |
| 240 | - | // pad renders s into a width-w row, blank-filled like a real terminal grid. | 240 | + | func padRow(s string, w int) []renderer.Cell { |
| 241 | - | pad := func(s string, w int) []renderer.Cell { | 241 | + | cells := make([]renderer.Cell, w) |
| 242 | - | cells := make([]renderer.Cell, w) | 242 | + | for i := range cells { |
| 243 | - | for i := range cells { | 243 | + | cells[i] = renderer.BlankCell() |
| 244 | - | cells[i] = renderer.BlankCell() | 244 | + | } |
| 245 | - | } | 245 | + | for i, r := range []rune(s) { |
| 246 | - | for i, r := range []rune(s) { | 246 | + | if i >= w { |
| 247 | - | if i >= w { | 247 | + | break |
| 248 | - | break | |||
| 249 | - | } | |||
| 250 | - | cells[i] = renderer.Cell{Rune: r} | |||
| 251 | } | 248 | } | ||
| 252 | - | return cells | 249 | + | cells[i] = renderer.Cell{Rune: r} |
| 253 | } | 250 | } | ||
| 251 | + | return cells | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func TestDocumentContentEnd(t *testing.T) { | |||
| 255 | + | tests := []struct { | |||
| 256 | + | name string | |||
| 257 | + | cells []renderer.Cell | |||
| 258 | + | bounds []vt.LineBound | |||
| 259 | + | want int | |||
| 260 | + | }{ | |||
| 261 | + | {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2}, | |||
| 262 | + | {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3}, | |||
| 263 | + | {"blank", padRow("", 8), []vt.LineBound{{}}, 0}, | |||
| 264 | + | {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8}, | |||
| 265 | + | {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5}, | |||
| 266 | + | {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3}, | |||
| 267 | + | {"nil bounds", padRow("ab", 8), nil, 2}, | |||
| 268 | + | } | |||
| 269 | + | for _, tt := range tests { | |||
| 270 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 271 | + | doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "") | |||
| 272 | + | require.Equal(t, tt.want, doc.contentEnd(0)) | |||
| 273 | + | }) | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||
| 254 | 278 | ||||
| 255 | tests := []struct { | 279 | tests := []struct { | ||
| 256 | name string | 280 | name string | ||
| @@ -261,28 +285,28 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||||
| 261 | }{ | 285 | }{ | ||
| 262 | { | 286 | { | ||
| 263 | name: "hard row drops its padding", | 287 | name: "hard row drops its padding", | ||
| 264 | - | rows: [][]renderer.Cell{pad("ab", 8)}, | 288 | + | rows: [][]renderer.Cell{padRow("ab", 8)}, |
| 265 | bounds: []vt.LineBound{{End: 2}}, | 289 | bounds: []vt.LineBound{{End: 2}}, | ||
| 266 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}}, | 290 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}}, | ||
| 267 | want: "ab", | 291 | want: "ab", | ||
| 268 | }, | 292 | }, | ||
| 269 | { | 293 | { | ||
| 270 | name: "soft row joins without a newline", | 294 | name: "soft row joins without a newline", | ||
| 271 | - | rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)}, | 295 | + | rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, |
| 272 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | 296 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | ||
| 273 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | 297 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | ||
| 274 | want: "abcdefghij", | 298 | want: "abcdefghij", | ||
| 275 | }, | 299 | }, | ||
| 276 | { | 300 | { | ||
| 277 | name: "soft row keeps a real trailing space", | 301 | name: "soft row keeps a real trailing space", | ||
| 278 | - | rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)}, | 302 | + | rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)}, |
| 279 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | 303 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | ||
| 280 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | 304 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | ||
| 281 | want: "abc de", | 305 | want: "abc de", | ||
| 282 | }, | 306 | }, | ||
| 283 | { | 307 | { | ||
| 284 | name: "three consecutive soft rows", | 308 | name: "three consecutive soft rows", | ||
| 285 | - | rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)}, | 309 | + | rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)}, |
| 286 | bounds: []vt.LineBound{ | 310 | bounds: []vt.LineBound{ | ||
| 287 | {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2}, | 311 | {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2}, | ||
| 288 | }, | 312 | }, | ||
| @@ -294,35 +318,35 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||||
| 294 | }, | 318 | }, | ||
| 295 | { | 319 | { | ||
| 296 | name: "hard rows keep their newline", | 320 | name: "hard rows keep their newline", | ||
| 297 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)}, | 321 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)}, |
| 298 | bounds: []vt.LineBound{{End: 2}, {End: 2}}, | 322 | bounds: []vt.LineBound{{End: 2}, {End: 2}}, | ||
| 299 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | 323 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | ||
| 300 | want: "ab\ncd", | 324 | want: "ab\ncd", | ||
| 301 | }, | 325 | }, | ||
| 302 | { | 326 | { | ||
| 303 | name: "sparse ranges do not join across a row gap", | 327 | name: "sparse ranges do not join across a row gap", | ||
| 304 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)}, | 328 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)}, |
| 305 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}}, | 329 | 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}}, | 330 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}}, | ||
| 307 | want: "ab\ncd", | 331 | want: "ab\ncd", | ||
| 308 | }, | 332 | }, | ||
| 309 | { | 333 | { | ||
| 310 | name: "invalid trailing range does not prevent final emitted row trimming", | 334 | name: "invalid trailing range does not prevent final emitted row trimming", | ||
| 311 | - | rows: [][]renderer.Cell{pad("ab", 8)}, | 335 | + | rows: [][]renderer.Cell{padRow("ab", 8)}, |
| 312 | bounds: []vt.LineBound{{End: 8, Soft: true}}, | 336 | bounds: []vt.LineBound{{End: 8, Soft: true}}, | ||
| 313 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}}, | 337 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}}, | ||
| 314 | want: "ab", | 338 | want: "ab", | ||
| 315 | }, | 339 | }, | ||
| 316 | { | 340 | { | ||
| 317 | name: "blank row survives as an empty line", | 341 | name: "blank row survives as an empty line", | ||
| 318 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)}, | 342 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)}, |
| 319 | bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}}, | 343 | 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}}, | 344 | 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", | 345 | want: "ab\n\ncd", | ||
| 322 | }, | 346 | }, | ||
| 323 | { | 347 | { | ||
| 324 | name: "the last row is trimmed even when soft", | 348 | name: "the last row is trimmed even when soft", | ||
| 325 | - | rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)}, | 349 | + | rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)}, |
| 326 | bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}}, | 350 | bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}}, | ||
| 327 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | 351 | ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}}, | ||
| 328 | want: "ab\ncd", | 352 | want: "ab\ncd", | ||
| @@ -333,7 +357,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||||
| 333 | // so End stops at 3 and column 3 is abandoned padding. | 357 | // so End stops at 3 and column 3 is abandoned padding. | ||
| 334 | rows: [][]renderer.Cell{ | 358 | rows: [][]renderer.Cell{ | ||
| 335 | {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()}, | 359 | {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()}, | ||
| 336 | - | pad("b", 4), | 360 | + | padRow("b", 4), |
| 337 | }, | 361 | }, | ||
| 338 | bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}}, | 362 | bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}}, | ||
| 339 | ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}}, | 363 | ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}}, | ||
| @@ -341,7 +365,7 @@ func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) { | |||||
| 341 | }, | 365 | }, | ||
| 342 | { | 366 | { | ||
| 343 | name: "selection stopping mid soft row", | 367 | name: "selection stopping mid soft row", | ||
| 344 | - | rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)}, | 368 | + | rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)}, |
| 345 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | 369 | bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}}, | ||
| 346 | ranges: []CellRange{{Row: 0, Start: 2, End: 4}}, | 370 | ranges: []CellRange{{Row: 0, Start: 2, End: 4}}, | ||
| 347 | want: "cde", | 371 | want: "cde", | ||
M internal/usecase/copy/mode.go +20 -3
| @@ -596,7 +596,7 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), | |||
| 596 | 596 | paint(y, row) | |
| 597 | 597 | continue | |
| 598 | 598 | } | |
| 599 | - | d.snapshot.CopyRow(src, row) | |
| 599 | + | n := d.snapshot.CopyRow(src, row) | |
| 600 | 600 | if match, ok := m.currentSearchMatchForRow(src); ok { | |
| 601 | 601 | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | |
| 602 | 602 | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| @@ -604,9 +604,24 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), | |||
| 604 | 604 | } | |
| 605 | 605 | cursorCovered := false | |
| 606 | 606 | if hasSelectionBounds { | |
| 607 | - | if r, ok := selectionBounds.rangeForRow(d, src); ok { | |
| 607 | + | bound := d.snapshot.Bound(src) | |
| 608 | + | width := d.RowWidth(src) | |
| 609 | + | cells, rangeCells := row[:n], row | |
| 610 | + | if n < width { | |
| 611 | + | cells = d.Row(src) | |
| 612 | + | rangeCells = cells | |
| 613 | + | } | |
| 614 | + | limit := contentEndOf(cells, bound) | |
| 615 | + | r, ok := selectionBounds.rangeForCells(src, rangeCells, limit) | |
| 616 | + | if ok { | |
| 608 | 617 | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | |
| 609 | - | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| 618 | + | toggleInverse(&row[x].Style) | |
| 619 | + | } | |
| 620 | + | if col, ok := selectionBounds.newlineMarkerAt(src, limit, width, bound.Soft); ok && col < len(row) { | |
| 621 | + | toggleInverse(&row[col].Style) | |
| 622 | + | if cursorValid && cursor.Row == src && cursor.Col == col { | |
| 623 | + | cursorCovered = true | |
| 624 | + | } | |
| 610 | 625 | } | |
| 611 | 626 | if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End { | |
| 612 | 627 | cursorCovered = true | |
| @@ -639,6 +654,8 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |||
| 639 | 654 | } | |
| 640 | 655 | return styles[idx], true | |
| 641 | 656 | } | |
| 657 | + | func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse } | |
| 658 | + | ||
| 642 | 659 | func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) { | |
| 643 | 660 | if !ok || style.Equal(inverseStyle()) { | |
| 644 | 661 | dst.Inverse = true | |
| @@ -596,7 +596,7 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), | |||||
| 596 | paint(y, row) | 596 | paint(y, row) | ||
| 597 | continue | 597 | continue | ||
| 598 | } | 598 | } | ||
| 599 | - | d.snapshot.CopyRow(src, row) | 599 | + | n := d.snapshot.CopyRow(src, row) |
| 600 | if match, ok := m.currentSearchMatchForRow(src); ok { | 600 | if match, ok := m.currentSearchMatchForRow(src); ok { | ||
| 601 | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | 601 | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | ||
| 602 | applySelectionStyle(&row[x].Style, selection, hasSelection) | 602 | applySelectionStyle(&row[x].Style, selection, hasSelection) | ||
| @@ -604,9 +604,24 @@ func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), | |||||
| 604 | } | 604 | } | ||
| 605 | cursorCovered := false | 605 | cursorCovered := false | ||
| 606 | if hasSelectionBounds { | 606 | if hasSelectionBounds { | ||
| 607 | - | if r, ok := selectionBounds.rangeForRow(d, src); ok { | 607 | + | bound := d.snapshot.Bound(src) |
| 608 | + | width := d.RowWidth(src) | |||
| 609 | + | cells, rangeCells := row[:n], row | |||
| 610 | + | if n < width { | |||
| 611 | + | cells = d.Row(src) | |||
| 612 | + | rangeCells = cells | |||
| 613 | + | } | |||
| 614 | + | limit := contentEndOf(cells, bound) | |||
| 615 | + | r, ok := selectionBounds.rangeForCells(src, rangeCells, limit) | |||
| 616 | + | if ok { | |||
| 608 | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | 617 | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | ||
| 609 | - | applySelectionStyle(&row[x].Style, selection, hasSelection) | 618 | + | toggleInverse(&row[x].Style) |
| 619 | + | } | |||
| 620 | + | if col, ok := selectionBounds.newlineMarkerAt(src, limit, width, bound.Soft); ok && col < len(row) { | |||
| 621 | + | toggleInverse(&row[col].Style) | |||
| 622 | + | if cursorValid && cursor.Row == src && cursor.Col == col { | |||
| 623 | + | cursorCovered = true | |||
| 624 | + | } | |||
| 610 | } | 625 | } | ||
| 611 | if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End { | 626 | if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End { | ||
| 612 | cursorCovered = true | 627 | cursorCovered = true | ||
| @@ -639,6 +654,8 @@ func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |||||
| 639 | } | 654 | } | ||
| 640 | return styles[idx], true | 655 | return styles[idx], true | ||
| 641 | } | 656 | } | ||
| 657 | + | func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse } | |||
| 658 | + | ||||
| 642 | func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) { | 659 | func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) { | ||
| 643 | if !ok || style.Equal(inverseStyle()) { | 660 | if !ok || style.Equal(inverseStyle()) { | ||
| 644 | dst.Inverse = true | 661 | dst.Inverse = true | ||
M internal/usecase/copy/mode_test.go +5 -2
| @@ -143,7 +143,7 @@ func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) { | |||
| 143 | 143 | require.Equal(t, "bcde", m.SelectedText()) | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | - | func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | |
| 146 | + | func TestCopyModeRenderSelectionInvertsSearchAndWideGlyph(t *testing.T) { | |
| 147 | 147 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}} | |
| 148 | 148 | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), "")) | |
| 149 | 149 | require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0)) | |
| @@ -151,6 +151,8 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | |||
| 151 | 151 | f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}) | |
| 152 | 152 | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | |
| 153 | 153 | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | |
| 154 | + | require.True(t, f.At(0, 0).Style.Inverse) | |
| 155 | + | require.True(t, f.At(1, 0).Style.Inverse) | |
| 154 | 156 | } | |
| 155 | 157 | ||
| 156 | 158 | func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | |
| @@ -202,7 +204,8 @@ func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testi | |||
| 202 | 204 | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |
| 203 | 205 | f := m.Render(renderer.DefaultStyle(), selection) | |
| 204 | 206 | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | |
| 205 | - | require.True(t, f.At(2, 0).Style.HasBackgroundRGB) | |
| 207 | + | require.True(t, f.At(2, 0).Style.Inverse) | |
| 208 | + | require.False(t, f.At(2, 0).Style.HasBackgroundRGB) | |
| 206 | 209 | } | |
| 207 | 210 | ||
| 208 | 211 | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | |
| @@ -143,7 +143,7 @@ func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) { | |||||
| 143 | require.Equal(t, "bcde", m.SelectedText()) | 143 | require.Equal(t, "bcde", m.SelectedText()) | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | - | func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | 146 | + | func TestCopyModeRenderSelectionInvertsSearchAndWideGlyph(t *testing.T) { |
| 147 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}} | 147 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}} | ||
| 148 | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), "")) | 148 | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), "")) | ||
| 149 | require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0)) | 149 | require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0)) | ||
| @@ -151,6 +151,8 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | |||||
| 151 | f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}) | 151 | f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}) | ||
| 152 | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | 152 | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | ||
| 153 | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | 153 | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | ||
| 154 | + | require.True(t, f.At(0, 0).Style.Inverse) | |||
| 155 | + | require.True(t, f.At(1, 0).Style.Inverse) | |||
| 154 | } | 156 | } | ||
| 155 | 157 | ||||
| 156 | func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | 158 | func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | ||
| @@ -202,7 +204,8 @@ func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testi | |||||
| 202 | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | 204 | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | ||
| 203 | f := m.Render(renderer.DefaultStyle(), selection) | 205 | f := m.Render(renderer.DefaultStyle(), selection) | ||
| 204 | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | 206 | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | ||
| 205 | - | require.True(t, f.At(2, 0).Style.HasBackgroundRGB) | 207 | + | require.True(t, f.At(2, 0).Style.Inverse) |
| 208 | + | require.False(t, f.At(2, 0).Style.HasBackgroundRGB) | |||
| 206 | } | 209 | } | ||
| 207 | 210 | ||||
| 208 | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | 211 | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | ||
M internal/usecase/copy/selection.go +30 -3
| @@ -63,8 +63,9 @@ func (s Selection) Ranges(doc *Document) []CellRange { | |||
| 63 | 63 | return ranges | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | // RangeForRow returns the canonical inclusive range for one document row. It | |
| 67 | - | // lets viewport renderers avoid materializing ranges for off-screen rows. | |
| 66 | + | // RangeForRow returns the canonical inclusive range for one document row. | |
| 67 | + | // End == Start-1 denotes a selected row with no cells. It lets viewport | |
| 68 | + | // renderers avoid materializing ranges for off-screen rows. | |
| 68 | 69 | func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) { | |
| 69 | 70 | bounds, ok := s.bounds(doc) | |
| 70 | 71 | if !ok { | |
| @@ -120,11 +121,21 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||
| 120 | 121 | return CellRange{}, false | |
| 121 | 122 | } | |
| 122 | 123 | cells := doc.Row(row) | |
| 124 | + | return b.rangeForCells(row, cells, contentEndOf(cells, doc.snapshot.Bound(row))) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (b selectionBounds) rangeForCells(row int, cells []renderer.Cell, limit int) (CellRange, bool) { | |
| 128 | + | if row < b.start.Row || row > b.end.Row { | |
| 129 | + | return CellRange{}, false | |
| 130 | + | } | |
| 123 | 131 | if len(cells) == 0 { | |
| 124 | 132 | return CellRange{Row: row}, true | |
| 125 | 133 | } | |
| 134 | + | if limit == 0 { | |
| 135 | + | return CellRange{Row: row, End: -1}, true | |
| 136 | + | } | |
| 126 | 137 | if b.linewise { | |
| 127 | - | return CellRange{Row: row, End: len(cells) - 1}, true | |
| 138 | + | return CellRange{Row: row, End: limit - 1}, true | |
| 128 | 139 | } | |
| 129 | 140 | ||
| 130 | 141 | start, end := 0, len(cells)-1 | |
| @@ -134,9 +145,25 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||
| 134 | 145 | if row == b.end.Row { | |
| 135 | 146 | end = glyphEnd(cells, b.end.Col) | |
| 136 | 147 | } | |
| 148 | + | end = min(end, limit-1) | |
| 149 | + | if start > end { | |
| 150 | + | end = start - 1 | |
| 151 | + | } | |
| 137 | 152 | return CellRange{Row: row, Start: start, End: end}, true | |
| 138 | 153 | } | |
| 139 | 154 | ||
| 155 | + | // newlineMarker reports the column marking a selected hard newline on row. | |
| 156 | + | func (b selectionBounds) newlineMarker(doc *Document, row int) (int, bool) { | |
| 157 | + | return b.newlineMarkerAt(row, doc.contentEnd(row), doc.Width(), doc.snapshot.Bound(row).Soft) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (b selectionBounds) newlineMarkerAt(row, end, width int, soft bool) (int, bool) { | |
| 161 | + | if row < b.start.Row || row > b.end.Row || soft || (!b.linewise && row == b.end.Row) { | |
| 162 | + | return 0, false | |
| 163 | + | } | |
| 164 | + | return end, end > 0 && end < width | |
| 165 | + | } | |
| 166 | + | ||
| 140 | 167 | // Text extracts the selection's ranges. The granularity is already encoded in | |
| 141 | 168 | // those ranges, so extraction applies the same padding and wrap rules to all. | |
| 142 | 169 | func (s Selection) Text(doc *Document) string { | |
| @@ -63,8 +63,9 @@ func (s Selection) Ranges(doc *Document) []CellRange { | |||||
| 63 | return ranges | 63 | return ranges | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | - | // RangeForRow returns the canonical inclusive range for one document row. It | 66 | + | // RangeForRow returns the canonical inclusive range for one document row. |
| 67 | - | // lets viewport renderers avoid materializing ranges for off-screen rows. | 67 | + | // End == Start-1 denotes a selected row with no cells. It lets viewport |
| 68 | + | // renderers avoid materializing ranges for off-screen rows. | |||
| 68 | func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) { | 69 | func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) { | ||
| 69 | bounds, ok := s.bounds(doc) | 70 | bounds, ok := s.bounds(doc) | ||
| 70 | if !ok { | 71 | if !ok { | ||
| @@ -120,11 +121,21 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||||
| 120 | return CellRange{}, false | 121 | return CellRange{}, false | ||
| 121 | } | 122 | } | ||
| 122 | cells := doc.Row(row) | 123 | cells := doc.Row(row) | ||
| 124 | + | return b.rangeForCells(row, cells, contentEndOf(cells, doc.snapshot.Bound(row))) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (b selectionBounds) rangeForCells(row int, cells []renderer.Cell, limit int) (CellRange, bool) { | |||
| 128 | + | if row < b.start.Row || row > b.end.Row { | |||
| 129 | + | return CellRange{}, false | |||
| 130 | + | } | |||
| 123 | if len(cells) == 0 { | 131 | if len(cells) == 0 { | ||
| 124 | return CellRange{Row: row}, true | 132 | return CellRange{Row: row}, true | ||
| 125 | } | 133 | } | ||
| 134 | + | if limit == 0 { | |||
| 135 | + | return CellRange{Row: row, End: -1}, true | |||
| 136 | + | } | |||
| 126 | if b.linewise { | 137 | if b.linewise { | ||
| 127 | - | return CellRange{Row: row, End: len(cells) - 1}, true | 138 | + | return CellRange{Row: row, End: limit - 1}, true |
| 128 | } | 139 | } | ||
| 129 | 140 | ||||
| 130 | start, end := 0, len(cells)-1 | 141 | start, end := 0, len(cells)-1 | ||
| @@ -134,9 +145,25 @@ func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |||||
| 134 | if row == b.end.Row { | 145 | if row == b.end.Row { | ||
| 135 | end = glyphEnd(cells, b.end.Col) | 146 | end = glyphEnd(cells, b.end.Col) | ||
| 136 | } | 147 | } | ||
| 148 | + | end = min(end, limit-1) | |||
| 149 | + | if start > end { | |||
| 150 | + | end = start - 1 | |||
| 151 | + | } | |||
| 137 | return CellRange{Row: row, Start: start, End: end}, true | 152 | return CellRange{Row: row, Start: start, End: end}, true | ||
| 138 | } | 153 | } | ||
| 139 | 154 | ||||
| 155 | + | // newlineMarker reports the column marking a selected hard newline on row. | |||
| 156 | + | func (b selectionBounds) newlineMarker(doc *Document, row int) (int, bool) { | |||
| 157 | + | return b.newlineMarkerAt(row, doc.contentEnd(row), doc.Width(), doc.snapshot.Bound(row).Soft) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (b selectionBounds) newlineMarkerAt(row, end, width int, soft bool) (int, bool) { | |||
| 161 | + | if row < b.start.Row || row > b.end.Row || soft || (!b.linewise && row == b.end.Row) { | |||
| 162 | + | return 0, false | |||
| 163 | + | } | |||
| 164 | + | return end, end > 0 && end < width | |||
| 165 | + | } | |||
| 166 | + | ||||
| 140 | // Text extracts the selection's ranges. The granularity is already encoded in | 167 | // 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. | 168 | // those ranges, so extraction applies the same padding and wrap rules to all. | ||
| 142 | func (s Selection) Text(doc *Document) string { | 169 | func (s Selection) Text(doc *Document) string { | ||