fix(copy): preserve selection during search navigation
- commit
- e769ef38ca3d997d0e7c3782dcfdba7c69971129
- author
- brice · · 2026-07-15 07:12 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- ef1f76f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +45 -7
M internal/usecase/copy/mode.go +18 -7
| @@ -248,7 +248,7 @@ func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) | |||
| 248 | 248 | } | |
| 249 | 249 | m.Searches = append(m.Searches[:0], matches...) | |
| 250 | 250 | m.SearchIndex = min(max(index, 0), len(matches)-1) | |
| 251 | - | m.SetPosition(Pos{Row: matches[m.SearchIndex].Row, Col: matches[m.SearchIndex].Start}) | |
| 251 | + | m.moveToSearchMatch(matches[m.SearchIndex]) | |
| 252 | 252 | return true | |
| 253 | 253 | } | |
| 254 | 254 | func (m *Mode) NextSearchMatch(delta int) bool { | |
| @@ -260,9 +260,18 @@ func (m *Mode) NextSearchMatch(delta int) bool { | |||
| 260 | 260 | m.SearchIndex += len(m.Searches) | |
| 261 | 261 | } | |
| 262 | 262 | match := m.Searches[m.SearchIndex] | |
| 263 | - | m.SetPosition(Pos{Row: match.Row, Col: match.Start}) | |
| 263 | + | m.moveToSearchMatch(match) | |
| 264 | 264 | return true | |
| 265 | 265 | } | |
| 266 | + | func (m *Mode) moveToSearchMatch(match SearchMatch) { | |
| 267 | + | if m == nil || m.document == nil { | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | if m.navigator.Set(m.document, Pos{Row: match.Row, Col: match.Start}) && m.selection.Enabled { | |
| 271 | + | m.selection.Extend(m.navigator.Pos) | |
| 272 | + | } | |
| 273 | + | m.adjustViewport() | |
| 274 | + | } | |
| 266 | 275 | func (m *Mode) SelectedText() string { | |
| 267 | 276 | if m == nil { | |
| 268 | 277 | return "" | |
| @@ -349,6 +358,7 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 349 | 358 | selected[r.Row] = append(selected[r.Row], r) | |
| 350 | 359 | } | |
| 351 | 360 | selection, hasSelection := optionalStyle(styles, 1) | |
| 361 | + | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |
| 352 | 362 | for y := range d.Height() { | |
| 353 | 363 | src := m.ViewportTop + y | |
| 354 | 364 | if src >= d.Len() { | |
| @@ -361,16 +371,17 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 361 | 371 | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| 362 | 372 | } | |
| 363 | 373 | } | |
| 364 | - | covered := false | |
| 374 | + | cursorCovered := false | |
| 365 | 375 | for _, r := range selected[src] { | |
| 366 | - | covered = true | |
| 367 | 376 | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | |
| 368 | 377 | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| 369 | 378 | } | |
| 379 | + | if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End { | |
| 380 | + | cursorCovered = true | |
| 381 | + | } | |
| 370 | 382 | } | |
| 371 | - | if src == m.navigator.Pos.Row && !covered && len(row) > 0 { | |
| 372 | - | col := min(max(m.navigator.Pos.Col, 0), len(row)-1) | |
| 373 | - | applySelectionStyle(&row[col].Style, selection, hasSelection) | |
| 383 | + | if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 { | |
| 384 | + | applySelectionStyle(&row[cursor.Col].Style, selection, hasSelection) | |
| 374 | 385 | } | |
| 375 | 386 | } | |
| 376 | 387 | status := inverseStyle() | |
| @@ -248,7 +248,7 @@ func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) | |||||
| 248 | } | 248 | } | ||
| 249 | m.Searches = append(m.Searches[:0], matches...) | 249 | m.Searches = append(m.Searches[:0], matches...) | ||
| 250 | m.SearchIndex = min(max(index, 0), len(matches)-1) | 250 | m.SearchIndex = min(max(index, 0), len(matches)-1) | ||
| 251 | - | m.SetPosition(Pos{Row: matches[m.SearchIndex].Row, Col: matches[m.SearchIndex].Start}) | 251 | + | m.moveToSearchMatch(matches[m.SearchIndex]) |
| 252 | return true | 252 | return true | ||
| 253 | } | 253 | } | ||
| 254 | func (m *Mode) NextSearchMatch(delta int) bool { | 254 | func (m *Mode) NextSearchMatch(delta int) bool { | ||
| @@ -260,9 +260,18 @@ func (m *Mode) NextSearchMatch(delta int) bool { | |||||
| 260 | m.SearchIndex += len(m.Searches) | 260 | m.SearchIndex += len(m.Searches) | ||
| 261 | } | 261 | } | ||
| 262 | match := m.Searches[m.SearchIndex] | 262 | match := m.Searches[m.SearchIndex] | ||
| 263 | - | m.SetPosition(Pos{Row: match.Row, Col: match.Start}) | 263 | + | m.moveToSearchMatch(match) |
| 264 | return true | 264 | return true | ||
| 265 | } | 265 | } | ||
| 266 | + | func (m *Mode) moveToSearchMatch(match SearchMatch) { | |||
| 267 | + | if m == nil || m.document == nil { | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 270 | + | if m.navigator.Set(m.document, Pos{Row: match.Row, Col: match.Start}) && m.selection.Enabled { | |||
| 271 | + | m.selection.Extend(m.navigator.Pos) | |||
| 272 | + | } | |||
| 273 | + | m.adjustViewport() | |||
| 274 | + | } | |||
| 266 | func (m *Mode) SelectedText() string { | 275 | func (m *Mode) SelectedText() string { | ||
| 267 | if m == nil { | 276 | if m == nil { | ||
| 268 | return "" | 277 | return "" | ||
| @@ -349,6 +358,7 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||||
| 349 | selected[r.Row] = append(selected[r.Row], r) | 358 | selected[r.Row] = append(selected[r.Row], r) | ||
| 350 | } | 359 | } | ||
| 351 | selection, hasSelection := optionalStyle(styles, 1) | 360 | selection, hasSelection := optionalStyle(styles, 1) | ||
| 361 | + | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |||
| 352 | for y := range d.Height() { | 362 | for y := range d.Height() { | ||
| 353 | src := m.ViewportTop + y | 363 | src := m.ViewportTop + y | ||
| 354 | if src >= d.Len() { | 364 | if src >= d.Len() { | ||
| @@ -361,16 +371,17 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||||
| 361 | applySelectionStyle(&row[x].Style, selection, hasSelection) | 371 | applySelectionStyle(&row[x].Style, selection, hasSelection) | ||
| 362 | } | 372 | } | ||
| 363 | } | 373 | } | ||
| 364 | - | covered := false | 374 | + | cursorCovered := false |
| 365 | for _, r := range selected[src] { | 375 | for _, r := range selected[src] { | ||
| 366 | - | covered = true | |||
| 367 | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | 376 | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | ||
| 368 | applySelectionStyle(&row[x].Style, selection, hasSelection) | 377 | applySelectionStyle(&row[x].Style, selection, hasSelection) | ||
| 369 | } | 378 | } | ||
| 379 | + | if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End { | |||
| 380 | + | cursorCovered = true | |||
| 381 | + | } | |||
| 370 | } | 382 | } | ||
| 371 | - | if src == m.navigator.Pos.Row && !covered && len(row) > 0 { | 383 | + | if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 { |
| 372 | - | col := min(max(m.navigator.Pos.Col, 0), len(row)-1) | 384 | + | applySelectionStyle(&row[cursor.Col].Style, selection, hasSelection) |
| 373 | - | applySelectionStyle(&row[col].Style, selection, hasSelection) | |||
| 374 | } | 385 | } | ||
| 375 | } | 386 | } | ||
| 376 | status := inverseStyle() | 387 | status := inverseStyle() | ||
M internal/usecase/copy/mode_test.go +27 -0
| @@ -76,6 +76,33 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | |||
| 76 | 76 | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | + | func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | |
| 80 | + | matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}} | |
| 81 | + | m := modeFor([]string{"zero", "one", "two"}, 3) | |
| 82 | + | ||
| 83 | + | m.SetPosition(Pos{Row: 0}) | |
| 84 | + | m.ToggleLineSelection() | |
| 85 | + | require.True(t, m.SetSearchMatches("match", matches, 0)) | |
| 86 | + | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection()) | |
| 87 | + | ||
| 88 | + | require.True(t, m.Right()) | |
| 89 | + | require.Equal(t, Character, m.Selection().Granularity) | |
| 90 | + | require.True(t, m.NextSearchMatch(1)) | |
| 91 | + | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection()) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) { | |
| 95 | + | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}} | |
| 96 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), "")) | |
| 97 | + | require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2})) | |
| 98 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head. | |
| 99 | + | ||
| 100 | + | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |
| 101 | + | f := m.Render(renderer.DefaultStyle(), selection) | |
| 102 | + | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | |
| 103 | + | require.True(t, f.At(2, 0).Style.HasBackgroundRGB) | |
| 104 | + | } | |
| 105 | + | ||
| 79 | 106 | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | |
| 80 | 107 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}} | |
| 81 | 108 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "") | |
| @@ -76,6 +76,33 @@ func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | |||||
| 76 | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | 76 | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | ||
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | + | func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | |||
| 80 | + | matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}} | |||
| 81 | + | m := modeFor([]string{"zero", "one", "two"}, 3) | |||
| 82 | + | ||||
| 83 | + | m.SetPosition(Pos{Row: 0}) | |||
| 84 | + | m.ToggleLineSelection() | |||
| 85 | + | require.True(t, m.SetSearchMatches("match", matches, 0)) | |||
| 86 | + | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection()) | |||
| 87 | + | ||||
| 88 | + | require.True(t, m.Right()) | |||
| 89 | + | require.Equal(t, Character, m.Selection().Granularity) | |||
| 90 | + | require.True(t, m.NextSearchMatch(1)) | |||
| 91 | + | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection()) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) { | |||
| 95 | + | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}} | |||
| 96 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), "")) | |||
| 97 | + | require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2})) | |||
| 98 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head. | |||
| 99 | + | ||||
| 100 | + | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |||
| 101 | + | f := m.Render(renderer.DefaultStyle(), selection) | |||
| 102 | + | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | |||
| 103 | + | require.True(t, f.At(2, 0).Style.HasBackgroundRGB) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 79 | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | 106 | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | ||
| 80 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}} | 107 | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}} | ||
| 81 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "") | 108 | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "") | ||