fix(copy): advance word end past completed words
- commit
- 6b1fde2e132a79804f76191480f1aa1d92607ed9
- author
- brice · · 2026-07-15 06:40 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- 5610a95
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +23 -1
M internal/usecase/copy/document.go +5 -1
| @@ -178,7 +178,7 @@ func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |||
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | // NextWordEnd returns the inclusive end of the current word, or of the next | |
| 181 | - | // word when pos is on a separator. | |
| 181 | + | // word when pos is on a separator or at the end of a word. | |
| 182 | 182 | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |
| 183 | 183 | pos, ok := d.Normalize(pos) | |
| 184 | 184 | if !ok { | |
| @@ -189,6 +189,10 @@ func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |||
| 189 | 189 | if i < 0 { | |
| 190 | 190 | return Pos{}, false | |
| 191 | 191 | } | |
| 192 | + | if !d.isSeparator(d.runeAt(glyphs[i])) && | |
| 193 | + | (i+1 == len(glyphs) || !d.sameWord(glyphs[i+1], glyphs[i])) { | |
| 194 | + | i++ | |
| 195 | + | } | |
| 192 | 196 | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | |
| 193 | 197 | i++ | |
| 194 | 198 | } | |
| @@ -178,7 +178,7 @@ func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |||||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | // NextWordEnd returns the inclusive end of the current word, or of the next | 180 | // NextWordEnd returns the inclusive end of the current word, or of the next | ||
| 181 | - | // word when pos is on a separator. | 181 | + | // word when pos is on a separator or at the end of a word. |
| 182 | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | 182 | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | ||
| 183 | pos, ok := d.Normalize(pos) | 183 | pos, ok := d.Normalize(pos) | ||
| 184 | if !ok { | 184 | if !ok { | ||
| @@ -189,6 +189,10 @@ func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |||||
| 189 | if i < 0 { | 189 | if i < 0 { | ||
| 190 | return Pos{}, false | 190 | return Pos{}, false | ||
| 191 | } | 191 | } | ||
| 192 | + | if !d.isSeparator(d.runeAt(glyphs[i])) && | |||
| 193 | + | (i+1 == len(glyphs) || !d.sameWord(glyphs[i+1], glyphs[i])) { | |||
| 194 | + | i++ | |||
| 195 | + | } | |||
| 192 | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | 196 | for i < len(glyphs) && d.isSeparator(d.runeAt(glyphs[i])) { | ||
| 193 | i++ | 197 | i++ | ||
| 194 | } | 198 | } | ||
M internal/usecase/copy/document_test.go +18 -0
| @@ -136,6 +136,24 @@ func TestDocumentWordMovement(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | + | func TestDocumentNextWordEndAdvancesPastCompletedWord(t *testing.T) { | |
| 140 | + | doc := documentFromStrings([]string{"alpha beta"}, "/") | |
| 141 | + | ||
| 142 | + | got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4}) | |
| 143 | + | ||
| 144 | + | require.True(t, ok) | |
| 145 | + | require.Equal(t, Pos{Row: 0, Col: 9}, got) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) { | |
| 149 | + | doc := documentFromStrings([]string{"alpha", "", "beta"}, "/") | |
| 150 | + | ||
| 151 | + | got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4}) | |
| 152 | + | ||
| 153 | + | require.True(t, ok) | |
| 154 | + | require.Equal(t, Pos{Row: 2, Col: 3}, got) | |
| 155 | + | } | |
| 156 | + | ||
| 139 | 157 | func TestDocumentExtractRanges(t *testing.T) { | |
| 140 | 158 | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 141 | 159 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | |
| @@ -136,6 +136,24 @@ func TestDocumentWordMovement(t *testing.T) { | |||||
| 136 | } | 136 | } | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | + | func TestDocumentNextWordEndAdvancesPastCompletedWord(t *testing.T) { | |||
| 140 | + | doc := documentFromStrings([]string{"alpha beta"}, "/") | |||
| 141 | + | ||||
| 142 | + | got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4}) | |||
| 143 | + | ||||
| 144 | + | require.True(t, ok) | |||
| 145 | + | require.Equal(t, Pos{Row: 0, Col: 9}, got) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) { | |||
| 149 | + | doc := documentFromStrings([]string{"alpha", "", "beta"}, "/") | |||
| 150 | + | ||||
| 151 | + | got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4}) | |||
| 152 | + | ||||
| 153 | + | require.True(t, ok) | |||
| 154 | + | require.Equal(t, Pos{Row: 2, Col: 3}, got) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 139 | func TestDocumentExtractRanges(t *testing.T) { | 157 | func TestDocumentExtractRanges(t *testing.T) { | ||
| 140 | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | 158 | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | ||
| 141 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | 159 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | ||