Merge pull request #96 from bnema/feature/scroll-copy-selection
feat(copy): add precise scrollback selection
32 files · +3877 -1260
M docs/configuration.md +14 -0
| @@ -70,6 +70,20 @@ Invalid values log a warning and keep the last valid value. | |||
| 70 | 70 | ||
| 71 | 71 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| 72 | 72 | ||
| 73 | + | ## Copy mode | |
| 74 | + | ||
| 75 | + | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | |
| 76 | + | ||
| 77 | + | Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words. | |
| 78 | + | ||
| 79 | + | ```ini | |
| 80 | + | # Unicode whitespace always separates words. | |
| 81 | + | # The default is " -_@". | |
| 82 | + | copy.word-separators = " -_@" | |
| 83 | + | ``` | |
| 84 | + | ||
| 85 | + | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | |
| 86 | + | ||
| 73 | 87 | ## Palette anchor | |
| 74 | 88 | ||
| 75 | 89 | `auto` uses a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | |
M internal/adapters/config/config.go +29 -0
| @@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 31 | 31 | var warnings []domain.Warning | |
| 32 | 32 | seenBindingKeys := make(map[string]bool) | |
| 33 | 33 | seenFloatingKeys := make(map[string]bool) | |
| 34 | + | seenCopyKeys := make(map[string]bool) | |
| 34 | 35 | seenPaletteKeys := make(map[string]bool) | |
| 35 | 36 | seenTabsKeys := make(map[string]bool) | |
| 36 | 37 | ||
| @@ -112,6 +113,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 112 | 113 | continue | |
| 113 | 114 | } | |
| 114 | 115 | cfg.Tabs.TerminalTitle = on | |
| 116 | + | case key == "copy.word-separators": | |
| 117 | + | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |
| 118 | + | separators, ok := parseConfigString(value) | |
| 119 | + | if !ok { | |
| 120 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)}) | |
| 121 | + | continue | |
| 122 | + | } | |
| 123 | + | cfg.Copy.WordSeparators = separators | |
| 115 | 124 | case key == "floating.command": | |
| 116 | 125 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 117 | 126 | command, ok := parseBarCommand(value) | |
| @@ -313,6 +322,26 @@ func parseBarCommand(value string) (string, bool) { | |||
| 313 | 322 | return value, true | |
| 314 | 323 | } | |
| 315 | 324 | ||
| 325 | + | func parseConfigString(value string) (string, bool) { | |
| 326 | + | value = strings.TrimSpace(value) | |
| 327 | + | if value == "" { | |
| 328 | + | return "", true | |
| 329 | + | } | |
| 330 | + | startsQuoted := strings.HasPrefix(value, `"`) | |
| 331 | + | endsQuoted := strings.HasSuffix(value, `"`) | |
| 332 | + | if startsQuoted != endsQuoted { | |
| 333 | + | return "", false | |
| 334 | + | } | |
| 335 | + | if !startsQuoted { | |
| 336 | + | return value, true | |
| 337 | + | } | |
| 338 | + | unquoted, err := strconv.Unquote(value) | |
| 339 | + | if err != nil { | |
| 340 | + | return "", false | |
| 341 | + | } | |
| 342 | + | return unquoted, true | |
| 343 | + | } | |
| 344 | + | ||
| 316 | 345 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 317 | 346 | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 318 | 347 | case "auto", "": | |
M internal/adapters/config/config_test.go +68 -0
| @@ -344,6 +344,9 @@ func TestParse(t *testing.T) { | |||
| 344 | 344 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 345 | 345 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 346 | 346 | } | |
| 347 | + | if tt.want.Copy.WordSeparators == "" { | |
| 348 | + | tt.want.Copy = domain.Defaults().Copy | |
| 349 | + | } | |
| 347 | 350 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 348 | 351 | if err != nil { | |
| 349 | 352 | t.Fatalf("Parse() error = %v", err) | |
| @@ -419,6 +422,71 @@ func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |||
| 419 | 422 | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | |
| 420 | 423 | } | |
| 421 | 424 | ||
| 425 | + | func TestParseCopyWordSeparators(t *testing.T) { | |
| 426 | + | t.Parallel() | |
| 427 | + | ||
| 428 | + | tests := []struct { | |
| 429 | + | name string | |
| 430 | + | input string | |
| 431 | + | want string | |
| 432 | + | wantWarnings []domain.Warning | |
| 433 | + | }{ | |
| 434 | + | { | |
| 435 | + | name: "default", | |
| 436 | + | input: "", | |
| 437 | + | want: domain.DefaultWordSeparators, | |
| 438 | + | }, | |
| 439 | + | { | |
| 440 | + | name: "quoted preserves leading separator space", | |
| 441 | + | input: `copy.word-separators = " -_@/"`, | |
| 442 | + | want: " -_@/", | |
| 443 | + | }, | |
| 444 | + | { | |
| 445 | + | name: "quoted empty is valid", | |
| 446 | + | input: `copy.word-separators = ""`, | |
| 447 | + | want: "", | |
| 448 | + | }, | |
| 449 | + | { | |
| 450 | + | name: "unquoted empty is valid", | |
| 451 | + | input: "copy.word-separators =", | |
| 452 | + | want: "", | |
| 453 | + | }, | |
| 454 | + | { | |
| 455 | + | name: "malformed quote keeps default", | |
| 456 | + | input: `copy.word-separators = "unterminated`, | |
| 457 | + | want: domain.DefaultWordSeparators, | |
| 458 | + | wantWarnings: []domain.Warning{ | |
| 459 | + | {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`}, | |
| 460 | + | }, | |
| 461 | + | }, | |
| 462 | + | { | |
| 463 | + | name: "duplicate last value wins", | |
| 464 | + | input: "copy.word-separators = /\ncopy.word-separators = _", | |
| 465 | + | want: "_", | |
| 466 | + | wantWarnings: []domain.Warning{ | |
| 467 | + | {Line: 2, Msg: `duplicate key "copy.word-separators"`}, | |
| 468 | + | }, | |
| 469 | + | }, | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | for _, tt := range tests { | |
| 473 | + | t.Run(tt.name, func(t *testing.T) { | |
| 474 | + | t.Parallel() | |
| 475 | + | ||
| 476 | + | cfg, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 477 | + | require.NoError(t, err) | |
| 478 | + | require.Equal(t, tt.want, cfg.Copy.WordSeparators) | |
| 479 | + | require.Equal(t, tt.wantWarnings, warnings) | |
| 480 | + | }) | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func TestDefaultsCopyWordSeparators(t *testing.T) { | |
| 485 | + | t.Parallel() | |
| 486 | + | ||
| 487 | + | require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators) | |
| 488 | + | } | |
| 489 | + | ||
| 422 | 490 | func TestParsePreservesBindingFileOrder(t *testing.T) { | |
| 423 | 491 | t.Parallel() | |
| 424 | 492 | ||
M internal/domain/config.go +12 -0
| @@ -5,6 +5,9 @@ import "time" | |||
| 5 | 5 | // MinBarInterval is the minimum supported bar refresh interval. | |
| 6 | 6 | const MinBarInterval = time.Second | |
| 7 | 7 | ||
| 8 | + | // DefaultWordSeparators matches tmux's practical shell-oriented word boundaries. | |
| 9 | + | const DefaultWordSeparators = " -_@" | |
| 10 | + | ||
| 8 | 11 | // ThemeMode selects how vev derives its color scheme. | |
| 9 | 12 | type ThemeMode int | |
| 10 | 13 | ||
| @@ -47,6 +50,11 @@ type BarConfig struct { | |||
| 47 | 50 | Interval time.Duration | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 53 | + | // CopyConfig contains copy-mode text selection settings. | |
| 54 | + | type CopyConfig struct { | |
| 55 | + | WordSeparators string | |
| 56 | + | } | |
| 57 | + | ||
| 50 | 58 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | |
| 51 | 59 | // adaptive automatic placement. | |
| 52 | 60 | type PaletteConfig struct { | |
| @@ -77,6 +85,7 @@ type Config struct { | |||
| 77 | 85 | BindingEntries []ConfigEntry | |
| 78 | 86 | Codes map[string]string | |
| 79 | 87 | Snapshot SnapshotConfig | |
| 88 | + | Copy CopyConfig | |
| 80 | 89 | Palette PaletteConfig | |
| 81 | 90 | Floating FloatingConfig | |
| 82 | 91 | Tabs TabsConfig | |
| @@ -103,6 +112,9 @@ func Defaults() Config { | |||
| 103 | 112 | Snapshot: SnapshotConfig{ | |
| 104 | 113 | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | |
| 105 | 114 | }, | |
| 115 | + | Copy: CopyConfig{ | |
| 116 | + | WordSeparators: DefaultWordSeparators, | |
| 117 | + | }, | |
| 106 | 118 | Floating: FloatingConfig{ | |
| 107 | 119 | Width: 80, | |
| 108 | 120 | Height: 80, | |
A internal/usecase/copy/document.go +364 -0
| @@ -0,0 +1,364 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "unicode" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Pos identifies a terminal display cell in a document. Valid positions are | |
| 11 | + | // always glyph heads; Normalize converts continuation cells to their head. | |
| 12 | + | type Pos struct { | |
| 13 | + | Row int | |
| 14 | + | Col int | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // CellRange is an inclusive display-cell range on one physical document row. | |
| 18 | + | type CellRange struct { | |
| 19 | + | Row int | |
| 20 | + | Start int | |
| 21 | + | End int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Document provides immutable display-cell and text operations over a Snapshot. | |
| 25 | + | type Document struct { | |
| 26 | + | snapshot Snapshot | |
| 27 | + | wordSeparators map[rune]struct{} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func NewDocument(snapshot Snapshot, separators string) *Document { | |
| 31 | + | set := make(map[rune]struct{}, len([]rune(separators))) | |
| 32 | + | for _, r := range separators { | |
| 33 | + | set[r] = struct{}{} | |
| 34 | + | } | |
| 35 | + | return &Document{snapshot: snapshot, wordSeparators: set} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (d *Document) Snapshot() Snapshot { return d.snapshot } | |
| 39 | + | func (d *Document) Len() int { return d.snapshot.Len() } | |
| 40 | + | func (d *Document) Width() int { return d.snapshot.Width } | |
| 41 | + | func (d *Document) Height() int { return d.snapshot.Height } | |
| 42 | + | func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) } | |
| 43 | + | ||
| 44 | + | // Normalize returns pos as a valid glyph head. Empty physical rows have the | |
| 45 | + | // stable logical position {row, 0}, which is useful to vertical navigation. | |
| 46 | + | func (d *Document) Normalize(pos Pos) (Pos, bool) { | |
| 47 | + | if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 { | |
| 48 | + | return Pos{}, false | |
| 49 | + | } | |
| 50 | + | row := d.Row(pos.Row) | |
| 51 | + | if len(row) == 0 { | |
| 52 | + | if pos.Col == 0 { | |
| 53 | + | return pos, true | |
| 54 | + | } | |
| 55 | + | return Pos{}, false | |
| 56 | + | } | |
| 57 | + | if pos.Col >= len(row) { | |
| 58 | + | return Pos{}, false | |
| 59 | + | } | |
| 60 | + | for pos.Col > 0 && row[pos.Col].Continuation { | |
| 61 | + | pos.Col-- | |
| 62 | + | } | |
| 63 | + | if row[pos.Col].Continuation { | |
| 64 | + | return Pos{}, false | |
| 65 | + | } | |
| 66 | + | return pos, true | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // PrevGlyph returns the previous glyph head on pos's physical row. | |
| 70 | + | func (d *Document) PrevGlyph(pos Pos) (Pos, bool) { | |
| 71 | + | pos, ok := d.Normalize(pos) | |
| 72 | + | if !ok || len(d.Row(pos.Row)) == 0 { | |
| 73 | + | return Pos{}, false | |
| 74 | + | } | |
| 75 | + | for col := pos.Col - 1; col >= 0; col-- { | |
| 76 | + | if !d.Row(pos.Row)[col].Continuation { | |
| 77 | + | return Pos{Row: pos.Row, Col: col}, true | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return Pos{}, false | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // NextGlyph returns the next glyph head on pos's physical row. | |
| 84 | + | func (d *Document) NextGlyph(pos Pos) (Pos, bool) { | |
| 85 | + | pos, ok := d.Normalize(pos) | |
| 86 | + | if !ok { | |
| 87 | + | return Pos{}, false | |
| 88 | + | } | |
| 89 | + | row := d.Row(pos.Row) | |
| 90 | + | for col := pos.Col + 1; col < len(row); col++ { | |
| 91 | + | if !row[col].Continuation { | |
| 92 | + | return Pos{Row: pos.Row, Col: col}, true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return Pos{}, false | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // WordBounds returns the inclusive bounds of the word containing pos. Unicode | |
| 99 | + | // whitespace and configured separators do not belong to words. | |
| 100 | + | func (d *Document) WordBounds(pos Pos) (Pos, Pos, bool) { | |
| 101 | + | pos, ok := d.Normalize(pos) | |
| 102 | + | if !ok || !d.isGlyph(pos) || d.isSeparator(d.runeAt(pos)) { | |
| 103 | + | return Pos{}, Pos{}, false | |
| 104 | + | } | |
| 105 | + | start, end := pos, pos | |
| 106 | + | for { | |
| 107 | + | prev, ok := d.PrevGlyph(start) | |
| 108 | + | if !ok || d.isSeparator(d.runeAt(prev)) { | |
| 109 | + | break | |
| 110 | + | } | |
| 111 | + | start = prev | |
| 112 | + | } | |
| 113 | + | for { | |
| 114 | + | next, ok := d.NextGlyph(end) | |
| 115 | + | if !ok || d.isSeparator(d.runeAt(next)) { | |
| 116 | + | break | |
| 117 | + | } | |
| 118 | + | end = next | |
| 119 | + | } | |
| 120 | + | return start, end, true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // NextWordStart returns the beginning of the next word after pos, crossing | |
| 124 | + | // physical rows as necessary. It walks only the rows between pos and the | |
| 125 | + | // target instead of materializing every glyph in the document. | |
| 126 | + | func (d *Document) NextWordStart(pos Pos) (Pos, bool) { | |
| 127 | + | pos, ok := d.Normalize(pos) | |
| 128 | + | if !ok { | |
| 129 | + | return Pos{}, false | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | candidate := pos | |
| 133 | + | if d.isGlyph(candidate) && !d.isSeparator(d.runeAt(candidate)) { | |
| 134 | + | for { | |
| 135 | + | next, ok := d.nextDocumentGlyph(candidate) | |
| 136 | + | if !ok { | |
| 137 | + | return Pos{}, false | |
| 138 | + | } | |
| 139 | + | if !d.sameWord(next, candidate) { | |
| 140 | + | candidate = next | |
| 141 | + | break | |
| 142 | + | } | |
| 143 | + | candidate = next | |
| 144 | + | } | |
| 145 | + | } else { | |
| 146 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |
| 147 | + | if !ok { | |
| 148 | + | return Pos{}, false | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | for d.isSeparator(d.runeAt(candidate)) { | |
| 152 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |
| 153 | + | if !ok { | |
| 154 | + | return Pos{}, false | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return candidate, true | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // PreviousWordStart returns the beginning of the current word, or of the | |
| 161 | + | // preceding word when pos is already at a word beginning. | |
| 162 | + | func (d *Document) PreviousWordStart(pos Pos) (Pos, bool) { | |
| 163 | + | pos, ok := d.Normalize(pos) | |
| 164 | + | if !ok { | |
| 165 | + | return Pos{}, false | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | if d.isGlyph(pos) && !d.isSeparator(d.runeAt(pos)) { | |
| 169 | + | previous, ok := d.previousDocumentGlyph(pos) | |
| 170 | + | if ok && d.sameWord(previous, pos) { | |
| 171 | + | for d.sameWord(previous, pos) { | |
| 172 | + | pos = previous | |
| 173 | + | previous, ok = d.previousDocumentGlyph(pos) | |
| 174 | + | if !ok { | |
| 175 | + | break | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return pos, true | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | candidate, ok := d.previousDocumentGlyph(pos) | |
| 183 | + | if !ok { | |
| 184 | + | return Pos{}, false | |
| 185 | + | } | |
| 186 | + | for d.isSeparator(d.runeAt(candidate)) { | |
| 187 | + | candidate, ok = d.previousDocumentGlyph(candidate) | |
| 188 | + | if !ok { | |
| 189 | + | return Pos{}, false | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | for { | |
| 193 | + | previous, ok := d.previousDocumentGlyph(candidate) | |
| 194 | + | if !ok || !d.sameWord(previous, candidate) { | |
| 195 | + | return candidate, true | |
| 196 | + | } | |
| 197 | + | candidate = previous | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // NextWordEnd returns the inclusive end of the current word, or of the next | |
| 202 | + | // word when pos is on a separator or at the end of a word. | |
| 203 | + | func (d *Document) NextWordEnd(pos Pos) (Pos, bool) { | |
| 204 | + | pos, ok := d.Normalize(pos) | |
| 205 | + | if !ok || !d.isGlyph(pos) { | |
| 206 | + | return Pos{}, false | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | candidate := pos | |
| 210 | + | if !d.isSeparator(d.runeAt(candidate)) { | |
| 211 | + | next, ok := d.nextDocumentGlyph(candidate) | |
| 212 | + | if !ok { | |
| 213 | + | return Pos{}, false | |
| 214 | + | } | |
| 215 | + | if !d.sameWord(next, candidate) { | |
| 216 | + | candidate = next | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | for d.isSeparator(d.runeAt(candidate)) { | |
| 220 | + | candidate, ok = d.nextDocumentGlyph(candidate) | |
| 221 | + | if !ok { | |
| 222 | + | return Pos{}, false | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | for { | |
| 226 | + | next, ok := d.nextDocumentGlyph(candidate) | |
| 227 | + | if !ok || !d.sameWord(next, candidate) { | |
| 228 | + | return candidate, true | |
| 229 | + | } | |
| 230 | + | candidate = next | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // LineText returns row text without duplicated continuation cells and retains | |
| 235 | + | // the established copy-mode behavior of trimming trailing blank spaces. | |
| 236 | + | func (d *Document) LineText(row int) string { | |
| 237 | + | return strings.TrimRight(d.cellsText(d.Row(row), 0, len(d.Row(row))-1), " ") | |
| 238 | + | } | |
| 239 | + | ||
| 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 { | |
| 243 | + | if len(ranges) == 0 { | |
| 244 | + | return "" | |
| 245 | + | } | |
| 246 | + | lines := make([]string, 0, len(ranges)) | |
| 247 | + | for _, r := range ranges { | |
| 248 | + | text, ok := d.rangeText(r) | |
| 249 | + | if !ok { | |
| 250 | + | continue | |
| 251 | + | } | |
| 252 | + | if linewise { | |
| 253 | + | text = strings.TrimRight(text, " ") | |
| 254 | + | } | |
| 255 | + | lines = append(lines, text) | |
| 256 | + | } | |
| 257 | + | return strings.Join(lines, "\n") | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (d *Document) rangeText(r CellRange) (string, bool) { | |
| 261 | + | if r.Row < 0 || r.Row >= d.Len() { | |
| 262 | + | return "", false | |
| 263 | + | } | |
| 264 | + | row := d.Row(r.Row) | |
| 265 | + | if len(row) == 0 { | |
| 266 | + | return "", r.Start == 0 && r.End == 0 | |
| 267 | + | } | |
| 268 | + | start, end := min(r.Start, r.End), max(r.Start, r.End) | |
| 269 | + | if end < 0 || start >= len(row) { | |
| 270 | + | return "", false | |
| 271 | + | } | |
| 272 | + | start = max(start, 0) | |
| 273 | + | end = min(end, len(row)-1) | |
| 274 | + | headStart, ok := d.Normalize(Pos{Row: r.Row, Col: start}) | |
| 275 | + | if !ok { | |
| 276 | + | return "", false | |
| 277 | + | } | |
| 278 | + | headEnd, ok := d.Normalize(Pos{Row: r.Row, Col: end}) | |
| 279 | + | if !ok { | |
| 280 | + | return "", false | |
| 281 | + | } | |
| 282 | + | return d.cellsText(row, headStart.Col, headEnd.Col), true | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func (d *Document) cellsText(row []renderer.Cell, start, end int) string { | |
| 286 | + | if start < 0 || end < start || start >= len(row) { | |
| 287 | + | return "" | |
| 288 | + | } | |
| 289 | + | end = min(end, len(row)-1) | |
| 290 | + | var text strings.Builder | |
| 291 | + | for col := start; col <= end; col++ { | |
| 292 | + | cell := row[col] | |
| 293 | + | if cell.Continuation { | |
| 294 | + | continue | |
| 295 | + | } | |
| 296 | + | if cell.Rune == 0 { | |
| 297 | + | text.WriteByte(' ') | |
| 298 | + | continue | |
| 299 | + | } | |
| 300 | + | text.WriteRune(cell.Rune) | |
| 301 | + | } | |
| 302 | + | return text.String() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func (d *Document) isSeparator(r rune) bool { | |
| 306 | + | if unicode.IsSpace(r) { | |
| 307 | + | return true | |
| 308 | + | } | |
| 309 | + | _, ok := d.wordSeparators[r] | |
| 310 | + | return ok | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func (d *Document) isGlyph(pos Pos) bool { | |
| 314 | + | row := d.Row(pos.Row) | |
| 315 | + | return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (d *Document) runeAt(pos Pos) rune { | |
| 319 | + | cell := d.Row(pos.Row)[pos.Col] | |
| 320 | + | if cell.Rune == 0 { | |
| 321 | + | return ' ' | |
| 322 | + | } | |
| 323 | + | return cell.Rune | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // nextDocumentGlyph finds the next glyph in document order without building | |
| 327 | + | // an index for unrelated scrollback rows. | |
| 328 | + | func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) { | |
| 329 | + | for row := pos.Row; row < d.Len(); row++ { | |
| 330 | + | cells := d.Row(row) | |
| 331 | + | start := 0 | |
| 332 | + | if row == pos.Row { | |
| 333 | + | start = pos.Col + 1 | |
| 334 | + | } | |
| 335 | + | for col := start; col < len(cells); col++ { | |
| 336 | + | if !cells[col].Continuation { | |
| 337 | + | return Pos{Row: row, Col: col}, true | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | return Pos{}, false | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // previousDocumentGlyph finds the previous glyph in document order without | |
| 345 | + | // building an index for unrelated scrollback rows. | |
| 346 | + | func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) { | |
| 347 | + | for row := pos.Row; row >= 0; row-- { | |
| 348 | + | cells := d.Row(row) | |
| 349 | + | start := len(cells) - 1 | |
| 350 | + | if row == pos.Row { | |
| 351 | + | start = min(pos.Col-1, start) | |
| 352 | + | } | |
| 353 | + | for col := start; col >= 0; col-- { | |
| 354 | + | if !cells[col].Continuation { | |
| 355 | + | return Pos{Row: row, Col: col}, true | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | return Pos{}, false | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (d *Document) sameWord(left, right Pos) bool { | |
| 363 | + | return left.Row == right.Row && !d.isSeparator(d.runeAt(left)) && !d.isSeparator(d.runeAt(right)) | |
| 364 | + | } | |
A internal/usecase/copy/document_test.go +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func documentCells(text string) []renderer.Cell { | |
| 11 | + | cells := make([]renderer.Cell, 0, len([]rune(text))) | |
| 12 | + | for _, r := range text { | |
| 13 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 14 | + | } | |
| 15 | + | return cells | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func documentFromStrings(lines []string, separators string) *Document { | |
| 19 | + | rows := make([][]renderer.Cell, len(lines)) | |
| 20 | + | for i, line := range lines { | |
| 21 | + | rows[i] = documentCells(line) | |
| 22 | + | } | |
| 23 | + | return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestDocumentNormalize(t *testing.T) { | |
| 27 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 28 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@") | |
| 29 | + | ||
| 30 | + | tests := []struct { | |
| 31 | + | name string | |
| 32 | + | in Pos | |
| 33 | + | want Pos | |
| 34 | + | ok bool | |
| 35 | + | }{ | |
| 36 | + | {"glyph head", Pos{0, 1}, Pos{0, 1}, true}, | |
| 37 | + | {"wide continuation", Pos{0, 2}, Pos{0, 1}, true}, | |
| 38 | + | {"empty row stable position", Pos{1, 0}, Pos{1, 0}, true}, | |
| 39 | + | {"negative row", Pos{-1, 0}, Pos{}, false}, | |
| 40 | + | {"past row", Pos{2, 0}, Pos{}, false}, | |
| 41 | + | {"negative column", Pos{0, -1}, Pos{}, false}, | |
| 42 | + | {"past column", Pos{0, 5}, Pos{}, false}, | |
| 43 | + | {"empty row past column", Pos{1, 1}, Pos{}, false}, | |
| 44 | + | } | |
| 45 | + | for _, tt := range tests { | |
| 46 | + | t.Run(tt.name, func(t *testing.T) { | |
| 47 | + | got, ok := doc.Normalize(tt.in) | |
| 48 | + | require.Equal(t, tt.ok, ok) | |
| 49 | + | if ok { | |
| 50 | + | require.Equal(t, tt.want, got) | |
| 51 | + | } | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestDocumentGlyphNavigation(t *testing.T) { | |
| 57 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 58 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@") | |
| 59 | + | ||
| 60 | + | tests := []struct { | |
| 61 | + | name string | |
| 62 | + | fn func(Pos) (Pos, bool) | |
| 63 | + | in Pos | |
| 64 | + | want Pos | |
| 65 | + | ok bool | |
| 66 | + | }{ | |
| 67 | + | {"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true}, | |
| 68 | + | {"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true}, | |
| 69 | + | {"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false}, | |
| 70 | + | {"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true}, | |
| 71 | + | {"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true}, | |
| 72 | + | {"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false}, | |
| 73 | + | } | |
| 74 | + | for _, tt := range tests { | |
| 75 | + | t.Run(tt.name, func(t *testing.T) { | |
| 76 | + | got, ok := tt.fn(tt.in) | |
| 77 | + | require.Equal(t, tt.ok, ok) | |
| 78 | + | if ok { | |
| 79 | + | require.Equal(t, tt.want, got) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestDocumentWordBounds(t *testing.T) { | |
| 86 | + | doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/") | |
| 87 | + | ||
| 88 | + | tests := []struct { | |
| 89 | + | name string | |
| 90 | + | in Pos | |
| 91 | + | start Pos | |
| 92 | + | end Pos | |
| 93 | + | ok bool | |
| 94 | + | }{ | |
| 95 | + | {"configured separator", Pos{0, 5}, Pos{}, Pos{}, false}, | |
| 96 | + | {"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true}, | |
| 97 | + | {"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false}, | |
| 98 | + | {"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true}, | |
| 99 | + | {"empty row", Pos{2, 0}, Pos{}, Pos{}, false}, | |
| 100 | + | {"out of range", Pos{4, 0}, Pos{}, Pos{}, false}, | |
| 101 | + | } | |
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | start, end, ok := doc.WordBounds(tt.in) | |
| 105 | + | require.Equal(t, tt.ok, ok) | |
| 106 | + | if ok { | |
| 107 | + | require.Equal(t, tt.start, start) | |
| 108 | + | require.Equal(t, tt.end, end) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestDocumentWordMovement(t *testing.T) { | |
| 115 | + | doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/") | |
| 116 | + | ||
| 117 | + | tests := []struct { | |
| 118 | + | name string | |
| 119 | + | fn func(Pos) (Pos, bool) | |
| 120 | + | in Pos | |
| 121 | + | want Pos | |
| 122 | + | }{ | |
| 123 | + | {"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}}, | |
| 124 | + | {"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}}, | |
| 125 | + | {"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}}, | |
| 126 | + | {"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}}, | |
| 127 | + | {"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}}, | |
| 128 | + | {"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}}, | |
| 129 | + | } | |
| 130 | + | for _, tt := range tests { | |
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |
| 132 | + | got, ok := tt.fn(tt.in) | |
| 133 | + | require.True(t, ok) | |
| 134 | + | require.Equal(t, tt.want, got) | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| 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 | + | ||
| 157 | + | func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) { | |
| 158 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}} | |
| 159 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/") | |
| 160 | + | ||
| 161 | + | tests := []struct { | |
| 162 | + | name string | |
| 163 | + | move func(Pos) (Pos, bool) | |
| 164 | + | in Pos | |
| 165 | + | want Pos | |
| 166 | + | }{ | |
| 167 | + | {"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}}, | |
| 168 | + | {"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}}, | |
| 169 | + | {"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}}, | |
| 170 | + | {"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}}, | |
| 171 | + | {"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}}, | |
| 172 | + | {"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}}, | |
| 173 | + | } | |
| 174 | + | for _, tt := range tests { | |
| 175 | + | t.Run(tt.name, func(t *testing.T) { | |
| 176 | + | got, ok := tt.move(tt.in) | |
| 177 | + | require.True(t, ok) | |
| 178 | + | require.Equal(t, tt.want, got) | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) { | |
| 184 | + | lines := make([]string, 8_192) | |
| 185 | + | lines[0] = "alpha/beta" | |
| 186 | + | for i := 1; i < len(lines); i++ { | |
| 187 | + | lines[i] = "unrelated" | |
| 188 | + | } | |
| 189 | + | doc := documentFromStrings(lines, "/") | |
| 190 | + | ||
| 191 | + | tests := []struct { | |
| 192 | + | name string | |
| 193 | + | move func(Pos) (Pos, bool) | |
| 194 | + | in Pos | |
| 195 | + | want Pos | |
| 196 | + | }{ | |
| 197 | + | {"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}}, | |
| 198 | + | {"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}}, | |
| 199 | + | {"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}}, | |
| 200 | + | } | |
| 201 | + | for _, tt := range tests { | |
| 202 | + | t.Run(tt.name, func(t *testing.T) { | |
| 203 | + | allocs := testing.AllocsPerRun(100, func() { | |
| 204 | + | got, ok := tt.move(tt.in) | |
| 205 | + | if !ok || got != tt.want { | |
| 206 | + | t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want) | |
| 207 | + | } | |
| 208 | + | }) | |
| 209 | + | require.Zero(t, allocs, "local word movement must not materialize the document") | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestDocumentExtractRanges(t *testing.T) { | |
| 215 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}} | |
| 216 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "") | |
| 217 | + | ||
| 218 | + | tests := []struct { | |
| 219 | + | name string | |
| 220 | + | ranges []CellRange | |
| 221 | + | linewise bool | |
| 222 | + | want string | |
| 223 | + | }{ | |
| 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, ""}, | |
| 231 | + | } | |
| 232 | + | for _, tt := range tests { | |
| 233 | + | t.Run(tt.name, func(t *testing.T) { | |
| 234 | + | require.Equal(t, tt.want, doc.Extract(tt.ranges, tt.linewise)) | |
| 235 | + | }) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | var benchmarkDocumentWordPos Pos | |
| 240 | + | ||
| 241 | + | func BenchmarkDocumentWordMovementLocal(b *testing.B) { | |
| 242 | + | lines := make([]string, 32_768) | |
| 243 | + | lines[0] = "alpha/beta" | |
| 244 | + | for i := 1; i < len(lines); i++ { | |
| 245 | + | lines[i] = "unrelated" | |
| 246 | + | } | |
| 247 | + | doc := documentFromStrings(lines, "/") | |
| 248 | + | moves := []func(Pos) (Pos, bool){ | |
| 249 | + | doc.NextWordStart, | |
| 250 | + | doc.PreviousWordStart, | |
| 251 | + | doc.NextWordEnd, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | b.ReportAllocs() | |
| 255 | + | b.ResetTimer() | |
| 256 | + | for i := 0; i < b.N; i++ { | |
| 257 | + | for _, move := range moves { | |
| 258 | + | pos, ok := move(Pos{Row: 0, Col: 2}) | |
| 259 | + | if !ok { | |
| 260 | + | b.Fatal("local word movement failed") | |
| 261 | + | } | |
| 262 | + | benchmarkDocumentWordPos = pos | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) { | |
| 268 | + | rows := [][]renderer.Cell{documentCells("one")} | |
| 269 | + | doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_") | |
| 270 | + | rows[0][0].Rune = 'x' | |
| 271 | + | ||
| 272 | + | require.Equal(t, 1, doc.Len()) | |
| 273 | + | require.Equal(t, 9, doc.Width()) | |
| 274 | + | require.Equal(t, 2, doc.Height()) | |
| 275 | + | require.Equal(t, "one", doc.LineText(0)) | |
| 276 | + | require.Equal(t, doc.Snapshot().Row(0), doc.Row(0)) | |
| 277 | + | require.Nil(t, doc.Row(1)) | |
| 278 | + | } | |
M internal/usecase/copy/mode.go +292 -250
| @@ -10,26 +10,15 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/pkg/vt" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | |
| 14 | - | // exactly one OSC 52 sequence. Splitting one clipboard copy across multiple OSC | |
| 15 | - | // 52 sequences replaces the clipboard repeatedly in common terminals, corrupting | |
| 16 | - | // the intended result, so oversized selections are deferred until a terminal- | |
| 17 | - | // specific continuation protocol is supported. | |
| 18 | 13 | const OSC52MaxPayloadBytes = 75_000 | |
| 19 | 14 | ||
| 20 | - | // Snapshot is the immutable scrollback-mode document: history followed by the | |
| 21 | - | // visible screen. VT HistoryView retains immutable chunks; the visible frame is | |
| 22 | - | // cloned once when the snapshot is constructed. | |
| 15 | + | // Snapshot is the immutable scrollback document: sealed history plus a cloned screen. | |
| 23 | 16 | type Snapshot struct { | |
| 24 | - | history vt.HistoryView | |
| 25 | - | screen renderer.Frame | |
| 26 | - | Width int | |
| 27 | - | Height int | |
| 17 | + | history vt.HistoryView | |
| 18 | + | screen renderer.Frame | |
| 19 | + | Width, Height int | |
| 28 | 20 | } | |
| 29 | 21 | ||
| 30 | - | // NewSnapshot seals the current VT history tail, then freezes that immutable | |
| 31 | - | // view and clones the visible screen. Sealing prevents repeated copy entry from | |
| 32 | - | // cloning every row in a partial tail. | |
| 33 | 22 | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 34 | 23 | var history vt.HistoryView | |
| 35 | 24 | if historySource != nil { | |
| @@ -37,11 +26,6 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |||
| 37 | 26 | } | |
| 38 | 27 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 39 | 28 | } | |
| 40 | - | ||
| 41 | - | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | |
| 42 | - | // It is intended for callers that already have a complete document rather than | |
| 43 | - | // a scrollback and visible frame. Height is viewport geometry, not a request to | |
| 44 | - | // append blank document rows. | |
| 45 | 29 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 46 | 30 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) | |
| 47 | 31 | for _, row := range rows { | |
| @@ -49,12 +33,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 49 | 33 | } | |
| 50 | 34 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | |
| 51 | 35 | } | |
| 52 | - | ||
| 53 | - | // Len returns the number of document rows. | |
| 54 | 36 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | |
| 55 | - | ||
| 56 | - | // Row returns document row i, or nil when i is out of range. History rows | |
| 57 | - | // borrow immutable history storage and must be treated as read-only. | |
| 58 | 37 | func (s Snapshot) Row(i int) []renderer.Cell { | |
| 59 | 38 | if i < 0 { | |
| 60 | 39 | return nil | |
| @@ -68,321 +47,388 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 68 | 47 | } | |
| 69 | 48 | return s.screen.Row(i) | |
| 70 | 49 | } | |
| 71 | - | ||
| 72 | 50 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |
| 73 | - | rowIndex := 0 | |
| 51 | + | i := 0 | |
| 74 | 52 | stopped := false | |
| 75 | - | s.history.Range(func(row []renderer.Cell) bool { | |
| 76 | - | if !yield(rowIndex, row) { | |
| 53 | + | s.history.Range(func(r []renderer.Cell) bool { | |
| 54 | + | if !yield(i, r) { | |
| 77 | 55 | stopped = true | |
| 78 | 56 | return false | |
| 79 | 57 | } | |
| 80 | - | rowIndex++ | |
| 58 | + | i++ | |
| 81 | 59 | return true | |
| 82 | 60 | }) | |
| 83 | 61 | if stopped { | |
| 84 | 62 | return | |
| 85 | 63 | } | |
| 86 | 64 | for y := range s.screen.Height { | |
| 87 | - | if !yield(rowIndex+y, s.screen.Row(y)) { | |
| 65 | + | if !yield(i+y, s.screen.Row(y)) { | |
| 88 | 66 | return | |
| 89 | 67 | } | |
| 90 | 68 | } | |
| 91 | 69 | } | |
| 92 | 70 | ||
| 93 | - | // Mode stores per-client scrollback viewport and line-selection state. | |
| 94 | 71 | type SearchMatch struct { | |
| 95 | - | Row int | |
| 96 | - | Start int | |
| 97 | - | End int | |
| 98 | - | Text string | |
| 99 | - | } | |
| 72 | + | Row, Start, End int | |
| 73 | + | Text string | |
| 74 | + | } // End is exclusive display-cell offset. | |
| 100 | 75 | ||
| 101 | 76 | type Mode struct { | |
| 77 | + | document *Document | |
| 78 | + | navigator Navigator | |
| 79 | + | selection Selection | |
| 102 | 80 | ViewportTop int | |
| 103 | - | Cursor int | |
| 104 | - | Anchor int | |
| 105 | - | Selecting bool | |
| 106 | 81 | SearchQuery string | |
| 107 | 82 | Searches []SearchMatch | |
| 108 | 83 | SearchIndex int | |
| 109 | 84 | } | |
| 110 | 85 | ||
| 111 | - | func NewMode(s Snapshot) *Mode { | |
| 112 | - | m := &Mode{Anchor: -1} | |
| 113 | - | m.ViewportTop = max(s.Len()-s.Height, 0) | |
| 114 | - | m.Cursor = max(s.Len()-1, 0) | |
| 115 | - | m.clamp(s) | |
| 86 | + | func NewMode(doc *Document) *Mode { | |
| 87 | + | m := &Mode{document: doc, SearchIndex: -1} | |
| 88 | + | if doc != nil && doc.Len() > 0 { | |
| 89 | + | m.navigator = NewNavigator(Pos{Row: doc.Len() - 1}) | |
| 90 | + | m.navigator.Set(doc, m.navigator.Pos) | |
| 91 | + | m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0) | |
| 92 | + | } | |
| 116 | 93 | return m | |
| 117 | 94 | } | |
| 118 | - | ||
| 119 | - | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | |
| 120 | - | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 121 | - | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | |
| 122 | - | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) } | |
| 123 | - | ||
| 124 | - | func FindMatches(s Snapshot, query string) []SearchMatch { | |
| 125 | - | query = strings.TrimSpace(query) | |
| 126 | - | if query == "" { | |
| 95 | + | func (m *Mode) Document() *Document { | |
| 96 | + | if m == nil { | |
| 127 | 97 | return nil | |
| 128 | 98 | } | |
| 129 | - | needle := lowerRunes(query) | |
| 130 | - | matches := make([]SearchMatch, 0) | |
| 131 | - | s.rangeRows(func(row int, cells []renderer.Cell) bool { | |
| 132 | - | haystack, cellIndexes := searchableCells(cells) | |
| 133 | - | var text string | |
| 134 | - | for start := 0; start+len(needle) <= len(haystack); { | |
| 135 | - | if !slices.Equal(haystack[start:start+len(needle)], needle) { | |
| 136 | - | start++ | |
| 137 | - | continue | |
| 138 | - | } | |
| 139 | - | if text == "" { | |
| 140 | - | text = rowString(cells) | |
| 141 | - | } | |
| 142 | - | end := start + len(needle) | |
| 143 | - | cellEnd := len(cells) | |
| 144 | - | if end < len(cellIndexes) { | |
| 145 | - | cellEnd = cellIndexes[end] | |
| 146 | - | } | |
| 147 | - | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | |
| 148 | - | start += len(needle) | |
| 149 | - | } | |
| 150 | - | return true | |
| 151 | - | }) | |
| 152 | - | return matches | |
| 99 | + | return m.document | |
| 153 | 100 | } | |
| 154 | - | ||
| 155 | - | func searchableCells(cells []renderer.Cell) ([]rune, []int) { | |
| 156 | - | runes := make([]rune, 0, len(cells)) | |
| 157 | - | cellIndexes := make([]int, 0, len(cells)) | |
| 158 | - | for x, cell := range cells { | |
| 159 | - | if cell.Continuation { | |
| 160 | - | continue | |
| 101 | + | func (m *Mode) Cursor() Pos { | |
| 102 | + | if m == nil { | |
| 103 | + | return Pos{} | |
| 104 | + | } | |
| 105 | + | return m.navigator.Pos | |
| 106 | + | } | |
| 107 | + | func (m *Mode) Selection() Selection { | |
| 108 | + | if m == nil { | |
| 109 | + | return Selection{} | |
| 110 | + | } | |
| 111 | + | return m.selection | |
| 112 | + | } | |
| 113 | + | func (m *Mode) SetPosition(p Pos) bool { | |
| 114 | + | if m == nil { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | changed := m.navigator.Set(m.document, p) | |
| 118 | + | m.adjustViewport() | |
| 119 | + | return changed | |
| 120 | + | } | |
| 121 | + | func (m *Mode) move(op func(*Document) bool, stream bool) bool { | |
| 122 | + | if m == nil || m.document == nil { | |
| 123 | + | return false | |
| 124 | + | } | |
| 125 | + | if stream && m.selection.Enabled && m.selection.Granularity == Line { | |
| 126 | + | m.selection.AsCharacter() | |
| 127 | + | } | |
| 128 | + | changed := op(m.document) | |
| 129 | + | if changed { | |
| 130 | + | if m.selection.Enabled { | |
| 131 | + | m.selection.Extend(m.navigator.Pos) | |
| 161 | 132 | } | |
| 162 | - | r := cell.Rune | |
| 163 | - | if r == 0 { | |
| 164 | - | r = ' ' | |
| 133 | + | m.adjustViewport() | |
| 134 | + | } | |
| 135 | + | return changed | |
| 136 | + | } | |
| 137 | + | func (m *Mode) Left() bool { return m.move(m.navigator.Left, true) } | |
| 138 | + | func (m *Mode) Right() bool { return m.move(m.navigator.Right, true) } | |
| 139 | + | func (m *Mode) Up() bool { return m.move(m.navigator.Up, false) } | |
| 140 | + | func (m *Mode) Down() bool { return m.move(m.navigator.Down, false) } | |
| 141 | + | func (m *Mode) WordNext() bool { return m.move(m.navigator.WordNext, true) } | |
| 142 | + | func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) } | |
| 143 | + | func (m *Mode) WordEnd() bool { return m.move(m.navigator.WordEnd, true) } | |
| 144 | + | func (m *Mode) MoveRows(delta int) bool { | |
| 145 | + | if delta == 0 { | |
| 146 | + | return false | |
| 147 | + | } | |
| 148 | + | changed := false | |
| 149 | + | op := m.Up | |
| 150 | + | if delta > 0 { | |
| 151 | + | op = m.Down | |
| 152 | + | } | |
| 153 | + | for range abs(delta) { | |
| 154 | + | if !op() { | |
| 155 | + | break | |
| 165 | 156 | } | |
| 166 | - | runes = append(runes, unicode.ToLower(r)) | |
| 167 | - | cellIndexes = append(cellIndexes, x) | |
| 157 | + | changed = true | |
| 168 | 158 | } | |
| 169 | - | return runes, cellIndexes | |
| 159 | + | return changed | |
| 170 | 160 | } | |
| 171 | - | ||
| 172 | - | func lowerRunes(s string) []rune { | |
| 173 | - | runes := []rune(s) | |
| 174 | - | for i, r := range runes { | |
| 175 | - | runes[i] = unicode.ToLower(r) | |
| 161 | + | func (m *Mode) Page(pages int) bool { | |
| 162 | + | if m == nil || m.document == nil { | |
| 163 | + | return false | |
| 176 | 164 | } | |
| 177 | - | return runes | |
| 165 | + | return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false) | |
| 178 | 166 | } | |
| 179 | - | ||
| 180 | - | func (m *Mode) Search(s Snapshot, query string) bool { | |
| 181 | - | return m.SetSearchMatches(s, query, FindMatches(s, query), 0) | |
| 167 | + | func (m *Mode) Top() bool { return m.move(m.navigator.Top, false) } | |
| 168 | + | func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) } | |
| 169 | + | func (m *Mode) AtBottom() bool { | |
| 170 | + | if m == nil || m.document == nil || m.document.Len() == 0 { | |
| 171 | + | return m != nil && m.ViewportTop == 0 | |
| 172 | + | } | |
| 173 | + | return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0) | |
| 182 | 174 | } | |
| 183 | - | ||
| 184 | - | func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool { | |
| 175 | + | func (m *Mode) ToggleLineSelection() { | |
| 176 | + | if m.selection.Enabled { | |
| 177 | + | m.selection = Selection{} | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | m.selection = NewLineSelection(m.navigator.Pos) | |
| 181 | + | } | |
| 182 | + | func (m *Mode) StartCharacterSelection(p Pos) bool { | |
| 183 | + | if m == nil || m.document == nil { | |
| 184 | + | return false | |
| 185 | + | } | |
| 186 | + | p, ok := m.document.Normalize(p) | |
| 187 | + | if !ok { | |
| 188 | + | return false | |
| 189 | + | } | |
| 190 | + | m.navigator.Set(m.document, p) | |
| 191 | + | m.selection = Selection{Anchor: p, Active: p, Granularity: Character, Enabled: true} | |
| 192 | + | m.adjustViewport() | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | func (m *Mode) ExtendCharacterSelection(p Pos) bool { | |
| 196 | + | if m == nil || !m.selection.Enabled { | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | p, ok := m.document.Normalize(p) | |
| 200 | + | if !ok { | |
| 201 | + | return false | |
| 202 | + | } | |
| 203 | + | m.selection.AsCharacter() | |
| 204 | + | m.selection.Extend(p) | |
| 205 | + | m.navigator.Set(m.document, p) | |
| 206 | + | m.adjustViewport() | |
| 207 | + | return true | |
| 208 | + | } | |
| 209 | + | func (m *Mode) SelectWordAt(p Pos) bool { | |
| 210 | + | if m == nil { | |
| 211 | + | return false | |
| 212 | + | } | |
| 213 | + | s, ok := NewWordSelection(m.document, p) | |
| 214 | + | if !ok { | |
| 215 | + | return false | |
| 216 | + | } | |
| 217 | + | m.selection = s | |
| 218 | + | m.navigator.Set(m.document, s.Active) | |
| 219 | + | m.adjustViewport() | |
| 220 | + | return true | |
| 221 | + | } | |
| 222 | + | func (m *Mode) ExtendWordSelection(p Pos) bool { | |
| 223 | + | if m == nil || !m.selection.Enabled { | |
| 224 | + | return false | |
| 225 | + | } | |
| 226 | + | _, end, ok := m.document.WordBounds(p) | |
| 227 | + | if !ok { | |
| 228 | + | return false | |
| 229 | + | } | |
| 230 | + | m.selection.Granularity = Word | |
| 231 | + | m.selection.Extend(end) | |
| 232 | + | m.navigator.Set(m.document, end) | |
| 233 | + | m.adjustViewport() | |
| 234 | + | return true | |
| 235 | + | } | |
| 236 | + | func (m *Mode) Search(query string) bool { | |
| 237 | + | if m == nil { | |
| 238 | + | return false | |
| 239 | + | } | |
| 240 | + | return m.SetSearchMatches(query, FindMatches(m.document, query), 0) | |
| 241 | + | } | |
| 242 | + | func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool { | |
| 243 | + | if m == nil { | |
| 244 | + | return false | |
| 245 | + | } | |
| 246 | + | m.SearchQuery = strings.TrimSpace(query) | |
| 185 | 247 | if len(matches) == 0 { | |
| 186 | - | m.SearchQuery = strings.TrimSpace(query) | |
| 187 | 248 | m.Searches = nil | |
| 188 | 249 | m.SearchIndex = -1 | |
| 189 | 250 | return false | |
| 190 | 251 | } | |
| 191 | - | m.SearchQuery = strings.TrimSpace(query) | |
| 192 | 252 | m.Searches = append(m.Searches[:0], matches...) | |
| 193 | - | if index < 0 { | |
| 194 | - | index = 0 | |
| 195 | - | } | |
| 196 | - | if index >= len(matches) { | |
| 197 | - | index = len(matches) - 1 | |
| 198 | - | } | |
| 199 | - | m.SearchIndex = index | |
| 200 | - | m.SetCursor(s, matches[index].Row) | |
| 253 | + | m.SearchIndex = min(max(index, 0), len(matches)-1) | |
| 254 | + | m.moveToSearchMatch(matches[m.SearchIndex]) | |
| 201 | 255 | return true | |
| 202 | 256 | } | |
| 203 | - | ||
| 204 | - | func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool { | |
| 205 | - | if len(m.Searches) == 0 || delta == 0 { | |
| 257 | + | func (m *Mode) NextSearchMatch(delta int) bool { | |
| 258 | + | if m == nil || len(m.Searches) == 0 || delta == 0 { | |
| 206 | 259 | return false | |
| 207 | 260 | } | |
| 208 | 261 | m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches) | |
| 209 | 262 | if m.SearchIndex < 0 { | |
| 210 | 263 | m.SearchIndex += len(m.Searches) | |
| 211 | 264 | } | |
| 212 | - | m.SetCursor(s, m.Searches[m.SearchIndex].Row) | |
| 265 | + | match := m.Searches[m.SearchIndex] | |
| 266 | + | m.moveToSearchMatch(match) | |
| 213 | 267 | return true | |
| 214 | 268 | } | |
| 215 | - | ||
| 216 | - | // SetCursor moves the visual cursor to row, clamping it to the snapshot and | |
| 217 | - | // scrolling the viewport just enough to keep it visible. | |
| 218 | - | func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) } | |
| 219 | - | ||
| 220 | - | // StartSelectionAt starts a line-wise visual selection at row. | |
| 221 | - | func (m *Mode) StartSelectionAt(s Snapshot, row int) { | |
| 222 | - | m.SetCursor(s, row) | |
| 223 | - | m.Anchor = m.Cursor | |
| 224 | - | m.Selecting = true | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // ExtendTo extends an active line-wise visual selection to row while preserving | |
| 228 | - | // the original anchor. | |
| 229 | - | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | |
| 230 | - | ||
| 231 | - | func (m *Mode) AtBottom(s Snapshot) bool { | |
| 232 | - | total := s.Len() | |
| 233 | - | if total == 0 { | |
| 234 | - | return m.Cursor == 0 && m.ViewportTop == 0 | |
| 269 | + | func (m *Mode) moveToSearchMatch(match SearchMatch) { | |
| 270 | + | if m == nil || m.document == nil { | |
| 271 | + | return | |
| 235 | 272 | } | |
| 236 | - | return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0) | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | func (m *Mode) ToggleSelection() { | |
| 240 | - | if m.Selecting { | |
| 241 | - | m.Selecting = false | |
| 242 | - | m.Anchor = -1 | |
| 273 | + | pos, ok := m.document.Normalize(Pos{Row: match.Row, Col: match.Start}) | |
| 274 | + | if !ok { | |
| 243 | 275 | return | |
| 244 | 276 | } | |
| 245 | - | m.Selecting = true | |
| 246 | - | m.Anchor = m.Cursor | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | func (m *Mode) setCursor(s Snapshot, cursor int) { | |
| 250 | - | m.Cursor = cursor | |
| 251 | - | m.clamp(s) | |
| 252 | - | if m.Cursor < m.ViewportTop { | |
| 253 | - | m.ViewportTop = m.Cursor | |
| 277 | + | m.navigator.Set(m.document, pos) | |
| 278 | + | if m.selection.Enabled { | |
| 279 | + | m.selection.Extend(m.navigator.Pos) | |
| 254 | 280 | } | |
| 255 | - | if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom { | |
| 256 | - | m.ViewportTop = m.Cursor - max(s.Height, 1) + 1 | |
| 281 | + | m.adjustViewport() | |
| 282 | + | } | |
| 283 | + | func (m *Mode) SelectedText() string { | |
| 284 | + | if m == nil { | |
| 285 | + | return "" | |
| 257 | 286 | } | |
| 258 | - | m.clamp(s) | |
| 287 | + | return m.selection.Text(m.document) | |
| 259 | 288 | } | |
| 260 | - | ||
| 261 | - | func (m *Mode) clamp(s Snapshot) { | |
| 262 | - | total := s.Len() | |
| 263 | - | if total == 0 { | |
| 264 | - | m.Cursor, m.ViewportTop = 0, 0 | |
| 289 | + | func (m *Mode) adjustViewport() { | |
| 290 | + | if m == nil || m.document == nil || m.document.Len() == 0 { | |
| 265 | 291 | return | |
| 266 | 292 | } | |
| 267 | - | m.Cursor = min(max(m.Cursor, 0), total-1) | |
| 268 | - | maxTop := max(total-max(s.Height, 1), 0) | |
| 269 | - | m.ViewportTop = min(max(m.ViewportTop, 0), maxTop) | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | func (m *Mode) SelectedBounds() (int, int, bool) { | |
| 273 | - | if !m.Selecting || m.Anchor < 0 { | |
| 274 | - | return 0, 0, false | |
| 293 | + | rows := max(m.document.Height(), 1) | |
| 294 | + | if m.navigator.Pos.Row < m.ViewportTop { | |
| 295 | + | m.ViewportTop = m.navigator.Pos.Row | |
| 275 | 296 | } | |
| 276 | - | lo, hi := m.Anchor, m.Cursor | |
| 277 | - | if lo > hi { | |
| 278 | - | lo, hi = hi, lo | |
| 297 | + | if m.navigator.Pos.Row >= m.ViewportTop+rows { | |
| 298 | + | m.ViewportTop = m.navigator.Pos.Row - rows + 1 | |
| 279 | 299 | } | |
| 280 | - | return lo, hi, true | |
| 300 | + | m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0)) | |
| 281 | 301 | } | |
| 282 | 302 | ||
| 283 | - | func (m *Mode) SelectedText(s Snapshot) string { | |
| 284 | - | lo, hi, ok := m.SelectedBounds() | |
| 285 | - | if !ok || s.Len() == 0 { | |
| 286 | - | return "" | |
| 303 | + | func FindMatches(doc *Document, query string) []SearchMatch { | |
| 304 | + | query = strings.TrimSpace(query) | |
| 305 | + | if doc == nil || query == "" { | |
| 306 | + | return nil | |
| 307 | + | } | |
| 308 | + | needle := lowerRunes(query) | |
| 309 | + | matches := []SearchMatch{} | |
| 310 | + | doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool { | |
| 311 | + | hay, indexes := searchableCells(cells) | |
| 312 | + | text := "" | |
| 313 | + | for start := 0; start+len(needle) <= len(hay); { | |
| 314 | + | if !slices.Equal(hay[start:start+len(needle)], needle) { | |
| 315 | + | start++ | |
| 316 | + | continue | |
| 317 | + | } | |
| 318 | + | if text == "" { | |
| 319 | + | text = doc.LineText(row) | |
| 320 | + | } | |
| 321 | + | end := start + len(needle) | |
| 322 | + | cellEnd := len(cells) | |
| 323 | + | if end < len(indexes) { | |
| 324 | + | cellEnd = indexes[end] | |
| 325 | + | } | |
| 326 | + | matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text}) | |
| 327 | + | start += len(needle) | |
| 328 | + | } | |
| 329 | + | return true | |
| 330 | + | }) | |
| 331 | + | return matches | |
| 332 | + | } | |
| 333 | + | func searchableCells(cells []renderer.Cell) ([]rune, []int) { | |
| 334 | + | rs := make([]rune, 0, len(cells)) | |
| 335 | + | is := make([]int, 0, len(cells)) | |
| 336 | + | for x, c := range cells { | |
| 337 | + | if c.Continuation { | |
| 338 | + | continue | |
| 339 | + | } | |
| 340 | + | r := c.Rune | |
| 341 | + | if r == 0 { | |
| 342 | + | r = ' ' | |
| 343 | + | } | |
| 344 | + | rs = append(rs, unicode.ToLower(r)) | |
| 345 | + | is = append(is, x) | |
| 287 | 346 | } | |
| 288 | - | lo = max(lo, 0) | |
| 289 | - | hi = min(hi, s.Len()-1) | |
| 290 | - | lines := make([]string, 0, hi-lo+1) | |
| 291 | - | for i := lo; i <= hi; i++ { | |
| 292 | - | lines = append(lines, rowString(s.Row(i))) | |
| 347 | + | return rs, is | |
| 348 | + | } | |
| 349 | + | func lowerRunes(s string) []rune { | |
| 350 | + | rs := []rune(s) | |
| 351 | + | for i, r := range rs { | |
| 352 | + | rs[i] = unicode.ToLower(r) | |
| 293 | 353 | } | |
| 294 | - | return strings.Join(lines, "\n") | |
| 354 | + | return rs | |
| 295 | 355 | } | |
| 296 | 356 | ||
| 297 | - | func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |
| 298 | - | m.clamp(s) | |
| 299 | - | frame := renderer.NewFrame(s.Width, s.Height+1) | |
| 300 | - | lo, hi, selected := m.SelectedBounds() | |
| 301 | - | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | |
| 302 | - | for y := range s.Height { | |
| 357 | + | func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |
| 358 | + | if m == nil || m.document == nil { | |
| 359 | + | return renderer.NewFrame(0, 0) | |
| 360 | + | } | |
| 361 | + | d := m.document | |
| 362 | + | frame := renderer.NewFrame(d.Width(), d.Height()+1) | |
| 363 | + | selectionBounds, hasSelectionBounds := m.selection.bounds(d) | |
| 364 | + | selection, hasSelection := optionalStyle(styles, 1) | |
| 365 | + | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |
| 366 | + | for y := range d.Height() { | |
| 303 | 367 | src := m.ViewportTop + y | |
| 304 | - | if src >= s.Len() { | |
| 368 | + | if src >= d.Len() { | |
| 305 | 369 | break | |
| 306 | 370 | } | |
| 307 | 371 | row := frame.Row(y) | |
| 308 | - | copy(row, s.Row(src)) | |
| 309 | - | lineSelected := selected && src >= lo && src <= hi | |
| 310 | - | if lineSelected { | |
| 311 | - | for x := range row { | |
| 312 | - | applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle) | |
| 372 | + | copy(row, d.Row(src)) | |
| 373 | + | if match, ok := m.currentSearchMatchForRow(src); ok { | |
| 374 | + | for x := max(match.Start, 0); x < min(match.End, len(row)); x++ { | |
| 375 | + | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| 313 | 376 | } | |
| 314 | 377 | } | |
| 315 | - | if match, ok := m.currentSearchMatchForRow(src); ok { | |
| 316 | - | for x := match.Start; x < match.End && x < len(row); x++ { | |
| 317 | - | applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle) | |
| 378 | + | cursorCovered := false | |
| 379 | + | if hasSelectionBounds { | |
| 380 | + | if r, ok := selectionBounds.rangeForRow(d, src); ok { | |
| 381 | + | for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ { | |
| 382 | + | applySelectionStyle(&row[x].Style, selection, hasSelection) | |
| 383 | + | } | |
| 384 | + | if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End { | |
| 385 | + | cursorCovered = true | |
| 386 | + | } | |
| 318 | 387 | } | |
| 319 | 388 | } | |
| 320 | - | if src == m.Cursor && !lineSelected && len(row) > 0 { | |
| 321 | - | applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle) | |
| 389 | + | if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 { | |
| 390 | + | cursorCol := min(cursor.Col, len(row)-1) | |
| 391 | + | applySelectionStyle(&row[cursorCol].Style, selection, hasSelection) | |
| 322 | 392 | } | |
| 323 | 393 | } | |
| 324 | - | style := inverseStyle() | |
| 394 | + | status := inverseStyle() | |
| 325 | 395 | if len(styles) > 0 { | |
| 326 | - | style = styles[0] | |
| 396 | + | status = styles[0] | |
| 327 | 397 | } | |
| 328 | - | drawCopyStatus(frame.Row(s.Height), m, s.Len(), style) | |
| 398 | + | drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status) | |
| 329 | 399 | return frame | |
| 330 | 400 | } | |
| 331 | - | ||
| 332 | 401 | func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) { | |
| 333 | - | if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) { | |
| 402 | + | if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) { | |
| 334 | 403 | return SearchMatch{}, false | |
| 335 | 404 | } | |
| 336 | - | match := m.Searches[m.SearchIndex] | |
| 337 | - | return match, match.Row == row | |
| 405 | + | v := m.Searches[m.SearchIndex] | |
| 406 | + | return v, v.Row == row | |
| 338 | 407 | } | |
| 339 | - | ||
| 340 | 408 | func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |
| 341 | 409 | if idx >= len(styles) { | |
| 342 | 410 | return renderer.Style{}, false | |
| 343 | 411 | } | |
| 344 | 412 | return styles[idx], true | |
| 345 | 413 | } | |
| 346 | - | ||
| 347 | - | func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) { | |
| 348 | - | if !ok || selection.Equal(inverseStyle()) { | |
| 414 | + | func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) { | |
| 415 | + | if !ok || style.Equal(inverseStyle()) { | |
| 349 | 416 | dst.Inverse = true | |
| 350 | 417 | return | |
| 351 | 418 | } | |
| 352 | - | *dst = selection | |
| 419 | + | *dst = style | |
| 353 | 420 | } | |
| 354 | - | ||
| 355 | - | func inverseStyle() renderer.Style { | |
| 356 | - | style := renderer.DefaultStyle() | |
| 357 | - | style.Inverse = true | |
| 358 | - | return style | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | func rowString(row []renderer.Cell) string { | |
| 362 | - | runes := make([]rune, 0, len(row)) | |
| 363 | - | for _, c := range row { | |
| 364 | - | if c.Continuation { | |
| 365 | - | continue | |
| 366 | - | } | |
| 367 | - | r := c.Rune | |
| 368 | - | if r == 0 { | |
| 369 | - | r = ' ' | |
| 370 | - | } | |
| 371 | - | runes = append(runes, r) | |
| 372 | - | } | |
| 373 | - | return strings.TrimRight(string(runes), " ") | |
| 374 | - | } | |
| 375 | - | ||
| 421 | + | func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s } | |
| 376 | 422 | func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) { | |
| 377 | 423 | for i := range row { | |
| 378 | 424 | row[i] = renderer.BlankCell() | |
| 379 | 425 | } | |
| 380 | 426 | text := " [SCROLL] " | |
| 381 | - | if m.Selecting { | |
| 427 | + | if m.selection.Enabled { | |
| 382 | 428 | text = " [SELECT] " | |
| 383 | 429 | } | |
| 384 | 430 | if total > 0 { | |
| 385 | - | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | |
| 431 | + | text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " " | |
| 386 | 432 | } else { | |
| 387 | 433 | text += "0/0 " | |
| 388 | 434 | } | |
| @@ -400,21 +446,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl | |||
| 400 | 446 | } | |
| 401 | 447 | } | |
| 402 | 448 | ||
| 403 | - | // OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence. | |
| 404 | - | // Oversized payloads return no sequence rather than emitting corrupting | |
| 405 | - | // multi-sequence replacements. | |
| 406 | 449 | func OSC52(text string) [][]byte { | |
| 407 | 450 | if len([]byte(text)) > OSC52MaxPayloadBytes { | |
| 408 | 451 | return nil | |
| 409 | 452 | } | |
| 410 | - | encoded := base64.StdEncoding.EncodeToString([]byte(text)) | |
| 411 | - | return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")} | |
| 453 | + | return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")} | |
| 412 | 454 | } | |
| 413 | - | ||
| 414 | - | // OSC52FromBase64 builds the normalized OSC 52 sequence to forward a | |
| 415 | - | // clipboard set request that a pane app already emitted as base64. It | |
| 416 | - | // returns nil if b64 is not valid base64 or its decoded length exceeds | |
| 417 | - | // OSC52MaxPayloadBytes — callers must drop the request silently in that case. | |
| 418 | 455 | func OSC52FromBase64(b64 string) []byte { | |
| 419 | 456 | decoded, err := base64.StdEncoding.DecodeString(b64) | |
| 420 | 457 | if err != nil || len(decoded) > OSC52MaxPayloadBytes { | |
| @@ -422,17 +459,22 @@ func OSC52FromBase64(b64 string) []byte { | |||
| 422 | 459 | } | |
| 423 | 460 | return []byte("\x1b]52;c;" + b64 + "\x07") | |
| 424 | 461 | } | |
| 425 | - | ||
| 426 | 462 | func strconvItoa(n int) string { | |
| 427 | 463 | if n == 0 { | |
| 428 | 464 | return "0" | |
| 429 | 465 | } | |
| 430 | - | var buf [20]byte | |
| 431 | - | i := len(buf) | |
| 466 | + | var b [20]byte | |
| 467 | + | i := len(b) | |
| 432 | 468 | for n > 0 { | |
| 433 | 469 | i-- | |
| 434 | - | buf[i] = byte('0' + n%10) | |
| 470 | + | b[i] = byte('0' + n%10) | |
| 435 | 471 | n /= 10 | |
| 436 | 472 | } | |
| 437 | - | return string(buf[i:]) | |
| 473 | + | return string(b[i:]) | |
| 474 | + | } | |
| 475 | + | func abs(n int) int { | |
| 476 | + | if n < 0 { | |
| 477 | + | return -n | |
| 478 | + | } | |
| 479 | + | return n | |
| 438 | 480 | } | |
M internal/usecase/copy/mode_test.go +206 -589
| @@ -6,636 +6,253 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | "github.com/bnema/vev/pkg/vt" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | - | var benchmarkSnapshotSink Snapshot | |
| 15 | - | ||
| 16 | 15 | func row(text string) []renderer.Cell { | |
| 17 | - | result := make([]renderer.Cell, 0, len([]rune(text))) | |
| 16 | + | cells := make([]renderer.Cell, 0, len([]rune(text))) | |
| 18 | 17 | for _, r := range text { | |
| 19 | - | result = append(result, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 20 | - | } | |
| 21 | - | return result | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | func newHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | |
| 25 | - | ||
| 26 | - | func rowText(cells []renderer.Cell) string { | |
| 27 | - | runes := make([]rune, len(cells)) | |
| 28 | - | for i, cell := range cells { | |
| 29 | - | runes[i] = cell.Rune | |
| 30 | - | } | |
| 31 | - | return string(runes) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func snapshot(lines []string, height int) Snapshot { | |
| 35 | - | rows := make([][]renderer.Cell, len(lines)) | |
| 36 | - | for i, line := range lines { | |
| 37 | - | rows[i] = row(line) | |
| 38 | - | } | |
| 39 | - | return NewSnapshotFromRows(rows, 16, height) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | func inverseRow(text string) []renderer.Cell { | |
| 43 | - | cells := row(text) | |
| 44 | - | for i := range cells { | |
| 45 | - | cells[i].Style.Inverse = true | |
| 18 | + | cells = append(cells, renderer.Cell{Rune: r}) | |
| 46 | 19 | } | |
| 47 | 20 | return cells | |
| 48 | 21 | } | |
| 49 | - | ||
| 50 | - | func TestCopyModeNavigationBounds(t *testing.T) { | |
| 51 | - | tests := []struct { | |
| 52 | - | name string | |
| 53 | - | lines []string | |
| 54 | - | height int | |
| 55 | - | ops func(*Mode, Snapshot) | |
| 56 | - | wantCursor int | |
| 57 | - | wantTop int | |
| 58 | - | }{ | |
| 59 | - | { | |
| 60 | - | name: "initial viewport starts at bottom", | |
| 61 | - | lines: []string{"00", "01", "02", "03", "04"}, | |
| 62 | - | height: 3, | |
| 63 | - | ops: func(*Mode, Snapshot) {}, | |
| 64 | - | wantCursor: 4, | |
| 65 | - | wantTop: 2, | |
| 66 | - | }, | |
| 67 | - | { | |
| 68 | - | name: "k clamps at top and scrolls viewport", | |
| 69 | - | lines: []string{"00", "01", "02", "03", "04"}, | |
| 70 | - | height: 3, | |
| 71 | - | ops: func(m *Mode, s Snapshot) { | |
| 72 | - | m.Move(s, -99) | |
| 73 | - | }, | |
| 74 | - | wantCursor: 0, | |
| 75 | - | wantTop: 0, | |
| 76 | - | }, | |
| 77 | - | { | |
| 78 | - | name: "j clamps at bottom", | |
| 79 | - | lines: []string{"00", "01", "02", "03", "04"}, | |
| 80 | - | height: 3, | |
| 81 | - | ops: func(m *Mode, s Snapshot) { | |
| 82 | - | m.Move(s, 99) | |
| 83 | - | }, | |
| 84 | - | wantCursor: 4, | |
| 85 | - | wantTop: 2, | |
| 86 | - | }, | |
| 87 | - | { | |
| 88 | - | name: "page up moves by viewport height", | |
| 89 | - | lines: []string{"00", "01", "02", "03", "04", "05", "06"}, | |
| 90 | - | height: 3, | |
| 91 | - | ops: func(m *Mode, s Snapshot) { | |
| 92 | - | m.Page(s, -1) | |
| 93 | - | }, | |
| 94 | - | wantCursor: 3, | |
| 95 | - | wantTop: 3, | |
| 96 | - | }, | |
| 97 | - | { | |
| 98 | - | name: "g and G jump to document bounds", | |
| 99 | - | lines: []string{"00", "01", "02", "03", "04"}, | |
| 100 | - | height: 3, | |
| 101 | - | ops: func(m *Mode, s Snapshot) { | |
| 102 | - | m.Top(s) | |
| 103 | - | m.Bottom(s) | |
| 104 | - | }, | |
| 105 | - | wantCursor: 4, | |
| 106 | - | wantTop: 2, | |
| 107 | - | }, | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | for _, tt := range tests { | |
| 111 | - | t.Run(tt.name, func(t *testing.T) { | |
| 112 | - | s := snapshot(tt.lines, tt.height) | |
| 113 | - | m := NewMode(s) | |
| 114 | - | tt.ops(m, s) | |
| 115 | - | if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop { | |
| 116 | - | t.Fatalf("cursor/top = %d/%d, want %d/%d", m.Cursor, m.ViewportTop, tt.wantCursor, tt.wantTop) | |
| 117 | - | } | |
| 118 | - | }) | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | func TestCopyModeAtBottom(t *testing.T) { | |
| 123 | - | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |
| 124 | - | m := NewMode(s) | |
| 125 | - | if !m.AtBottom(s) { | |
| 126 | - | t.Fatalf("AtBottom() after NewMode = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | m.Move(s, -1) | |
| 130 | - | if m.AtBottom(s) { | |
| 131 | - | t.Fatalf("AtBottom() after moving above bottom = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | m.Bottom(s) | |
| 135 | - | if !m.AtBottom(s) { | |
| 136 | - | t.Fatalf("AtBottom() after Bottom = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | m.Top(s) | |
| 140 | - | if m.AtBottom(s) { | |
| 141 | - | t.Fatalf("AtBottom() after Top = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) { | |
| 146 | - | s := snapshot([]string{"00", "01"}, 5) | |
| 147 | - | m := NewMode(s) | |
| 148 | - | if !m.AtBottom(s) { | |
| 149 | - | t.Fatalf("AtBottom() after NewMode on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | m.Top(s) | |
| 153 | - | if m.AtBottom(s) { | |
| 154 | - | t.Fatalf("AtBottom() after Top on short snapshot = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | m.Bottom(s) | |
| 158 | - | if !m.AtBottom(s) { | |
| 159 | - | t.Fatalf("AtBottom() after Bottom on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | func TestCopyModeSelectionAPI(t *testing.T) { | |
| 164 | - | tests := []struct { | |
| 165 | - | name string | |
| 166 | - | ops func(*Mode, Snapshot) | |
| 167 | - | wantCursor int | |
| 168 | - | wantTop int | |
| 169 | - | wantAnchor int | |
| 170 | - | wantSelecting bool | |
| 22 | + | func modeFor(lines []string, height int) *Mode { | |
| 23 | + | rows := make([][]renderer.Cell, len(lines)) | |
| 24 | + | for i := range lines { | |
| 25 | + | rows[i] = row(lines[i]) | |
| 26 | + | } | |
| 27 | + | return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators)) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestCopyModeKeyboardSelection(t *testing.T) { | |
| 31 | + | m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2) | |
| 32 | + | require.Equal(t, Pos{Row: 3}, m.Cursor()) | |
| 33 | + | m.Top() | |
| 34 | + | m.ToggleLineSelection() | |
| 35 | + | m.Down() | |
| 36 | + | require.Equal(t, Line, m.Selection().Granularity) | |
| 37 | + | require.Equal(t, "alpha\nxy", m.SelectedText()) | |
| 38 | + | m.Right() | |
| 39 | + | require.Equal(t, Character, m.Selection().Granularity) | |
| 40 | + | require.Equal(t, Pos{Row: 0}, m.Selection().Anchor) | |
| 41 | + | require.Equal(t, Pos{Row: 1, Col: 1}, m.Selection().Active) | |
| 42 | + | require.True(t, m.Left()) | |
| 43 | + | require.Equal(t, Pos{Row: 1}, m.Cursor()) | |
| 44 | + | require.True(t, m.Right()) | |
| 45 | + | require.True(t, m.Up()) | |
| 46 | + | require.Equal(t, Pos{Row: 0, Col: 1}, m.Cursor()) | |
| 47 | + | require.True(t, m.Down()) | |
| 48 | + | require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor()) | |
| 49 | + | require.True(t, m.WordNext()) | |
| 50 | + | require.Equal(t, Pos{Row: 2}, m.Cursor()) | |
| 51 | + | require.True(t, m.WordEnd()) | |
| 52 | + | require.Equal(t, Pos{Row: 2, Col: 4}, m.Cursor()) | |
| 53 | + | require.True(t, m.WordBackward()) | |
| 54 | + | require.Equal(t, Pos{Row: 2}, m.Cursor()) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) { | |
| 58 | + | m := modeFor([]string{"abcdef", "xy", "abcdef"}, 2) | |
| 59 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 5})) | |
| 60 | + | m.Down() | |
| 61 | + | require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor()) | |
| 62 | + | m.Down() | |
| 63 | + | require.Equal(t, Pos{Row: 2, Col: 5}, m.Cursor()) | |
| 64 | + | m.SetPosition(Pos{Row: 0, Col: 1}) | |
| 65 | + | require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 1})) | |
| 66 | + | require.True(t, m.ExtendCharacterSelection(Pos{Row: 0, Col: 4})) | |
| 67 | + | require.Equal(t, "bcde", m.SelectedText()) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) { | |
| 71 | + | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}} | |
| 72 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), "")) | |
| 73 | + | require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0)) | |
| 74 | + | require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 0})) | |
| 75 | + | f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}) | |
| 76 | + | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | |
| 77 | + | require.True(t, f.At(1, 0).Style.HasBackgroundRGB) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) { | |
| 81 | + | matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}} | |
| 82 | + | m := modeFor([]string{"zero", "one", "two"}, 3) | |
| 83 | + | ||
| 84 | + | m.SetPosition(Pos{Row: 0}) | |
| 85 | + | m.ToggleLineSelection() | |
| 86 | + | require.True(t, m.SetSearchMatches("match", matches, 0)) | |
| 87 | + | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection()) | |
| 88 | + | ||
| 89 | + | require.True(t, m.Right()) | |
| 90 | + | require.Equal(t, Character, m.Selection().Granularity) | |
| 91 | + | require.True(t, m.NextSearchMatch(1)) | |
| 92 | + | require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection()) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestCopyModeSearchExtendsSelectionWhenMatchIsAlreadyAtCursor(t *testing.T) { | |
| 96 | + | m := modeFor([]string{"zero", "one"}, 2) | |
| 97 | + | require.True(t, m.StartCharacterSelection(Pos{Row: 0})) | |
| 98 | + | require.True(t, m.ExtendCharacterSelection(Pos{Row: 1, Col: 1})) | |
| 99 | + | require.True(t, m.SetPosition(Pos{Row: 0})) | |
| 100 | + | ||
| 101 | + | require.True(t, m.SetSearchMatches("zero", []SearchMatch{{Row: 0, Start: 0, End: 4}}, 0)) | |
| 102 | + | require.Equal(t, Pos{Row: 0}, m.Selection().Active) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestCopyModeSearchNilMode(t *testing.T) { | |
| 106 | + | var mode *Mode | |
| 107 | + | require.False(t, mode.Search("anything")) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) { | |
| 111 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), "")) | |
| 112 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame") | |
| 113 | + | ||
| 114 | + | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |
| 115 | + | require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) }) | |
| 116 | + | frame := m.Render(renderer.DefaultStyle(), selection) | |
| 117 | + | require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) { | |
| 121 | + | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}} | |
| 122 | + | m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), "")) | |
| 123 | + | require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2})) | |
| 124 | + | require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head. | |
| 125 | + | ||
| 126 | + | selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}} | |
| 127 | + | f := m.Render(renderer.DefaultStyle(), selection) | |
| 128 | + | require.True(t, f.At(0, 0).Style.HasBackgroundRGB) | |
| 129 | + | require.True(t, f.At(2, 0).Style.HasBackgroundRGB) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) { | |
| 133 | + | cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}} | |
| 134 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "") | |
| 135 | + | require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha")) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) { | |
| 139 | + | cells := []renderer.Cell{ | |
| 140 | + | {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true}, | |
| 141 | + | {Rune: 'ä'}, {Rune: '界'}, {Continuation: true}, | |
| 142 | + | {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true}, | |
| 143 | + | } | |
| 144 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "") | |
| 145 | + | ||
| 146 | + | for _, tt := range []struct { | |
| 147 | + | name, query string | |
| 148 | + | want []SearchMatch | |
| 171 | 149 | }{ | |
| 172 | 150 | { | |
| 173 | - | name: "SetCursor clamps and scrolls viewport", | |
| 174 | - | ops: func(m *Mode, s Snapshot) { | |
| 175 | - | m.SetCursor(s, -10) | |
| 176 | - | }, | |
| 177 | - | wantCursor: 0, | |
| 178 | - | wantTop: 0, | |
| 179 | - | wantAnchor: -1, | |
| 180 | - | }, | |
| 181 | - | { | |
| 182 | - | name: "StartSelectionAt sets cursor anchor and selecting", | |
| 183 | - | ops: func(m *Mode, s Snapshot) { | |
| 184 | - | m.StartSelectionAt(s, 2) | |
| 151 | + | name: "repeated non-overlapping", | |
| 152 | + | query: "aa", | |
| 153 | + | want: []SearchMatch{ | |
| 154 | + | {Row: 0, Start: 0, End: 2, Text: "aaaa"}, | |
| 155 | + | {Row: 0, Start: 2, End: 4, Text: "aaaa"}, | |
| 185 | 156 | }, | |
| 186 | - | wantCursor: 2, | |
| 187 | - | wantTop: 2, | |
| 188 | - | wantAnchor: 2, | |
| 189 | - | wantSelecting: true, | |
| 190 | 157 | }, | |
| 191 | 158 | { | |
| 192 | - | name: "ExtendTo preserves anchor while moving cursor", | |
| 193 | - | ops: func(m *Mode, s Snapshot) { | |
| 194 | - | m.StartSelectionAt(s, 1) | |
| 195 | - | m.ExtendTo(s, 4) | |
| 159 | + | name: "unicode display cells", | |
| 160 | + | query: "Ä界", | |
| 161 | + | want: []SearchMatch{ | |
| 162 | + | {Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"}, | |
| 163 | + | {Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"}, | |
| 164 | + | {Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"}, | |
| 196 | 165 | }, | |
| 197 | - | wantCursor: 4, | |
| 198 | - | wantTop: 2, | |
| 199 | - | wantAnchor: 1, | |
| 200 | - | wantSelecting: true, | |
| 201 | 166 | }, | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | for _, tt := range tests { | |
| 167 | + | } { | |
| 205 | 168 | t.Run(tt.name, func(t *testing.T) { | |
| 206 | - | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |
| 207 | - | m := NewMode(s) | |
| 208 | - | tt.ops(m, s) | |
| 209 | - | if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop || m.Anchor != tt.wantAnchor || m.Selecting != tt.wantSelecting { | |
| 210 | - | t.Fatalf("mode = cursor:%d top:%d anchor:%d selecting:%v, want cursor:%d top:%d anchor:%d selecting:%v", m.Cursor, m.ViewportTop, m.Anchor, m.Selecting, tt.wantCursor, tt.wantTop, tt.wantAnchor, tt.wantSelecting) | |
| 211 | - | } | |
| 169 | + | require.Equal(t, tt.want, FindMatches(doc, tt.query)) | |
| 212 | 170 | }) | |
| 213 | 171 | } | |
| 214 | 172 | } | |
| 215 | 173 | ||
| 216 | - | func TestCopyModeExtendToAboveAnchorSwapsSelectedBounds(t *testing.T) { | |
| 217 | - | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |
| 218 | - | m := NewMode(s) | |
| 219 | - | ||
| 220 | - | m.StartSelectionAt(s, 4) | |
| 221 | - | m.ExtendTo(s, 1) | |
| 222 | - | ||
| 223 | - | if m.Cursor != 1 || m.ViewportTop != 1 || m.Anchor != 4 || !m.Selecting { | |
| 224 | - | t.Fatalf("mode = cursor:%d top:%d anchor:%d selecting:%v, want cursor:1 top:1 anchor:4 selecting:true", m.Cursor, m.ViewportTop, m.Anchor, m.Selecting) | |
| 225 | - | } | |
| 226 | - | lo, hi, ok := m.SelectedBounds() | |
| 227 | - | if !ok || lo != 1 || hi != 4 { | |
| 228 | - | t.Fatalf("SelectedBounds = (%d,%d,%v), want (1,4,true)", lo, hi, ok) | |
| 229 | - | } | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | |
| 233 | - | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 234 | - | m := NewMode(s) | |
| 235 | - | ||
| 236 | - | frame := m.Render(s) | |
| 237 | - | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SELECT]") { | |
| 238 | - | t.Fatalf("passive status = %q, want [SCROLL] with N/M and no visual/select label", got) | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | m.ToggleSelection() | |
| 242 | - | frame = m.Render(s) | |
| 243 | - | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[VISUAL]") { | |
| 244 | - | t.Fatalf("selection status = %q, want [SELECT] with N/M and no passive label", got) | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |
| 249 | - | s := NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2) | |
| 250 | - | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 251 | - | m.Move(s, 1) | |
| 252 | - | ||
| 253 | - | if got, want := m.SelectedText(s), "alpha\nbeta"; got != want { | |
| 254 | - | t.Fatalf("SelectedText() = %q, want %q", got, want) | |
| 255 | - | } | |
| 256 | - | frame := m.Render(s) | |
| 257 | - | for y := range 2 { | |
| 258 | - | for x, c := range frame.Row(y) { | |
| 259 | - | if !c.Style.Inverse { | |
| 260 | - | t.Fatalf("row %d cell %d not inverse", y, x) | |
| 261 | - | } | |
| 262 | - | } | |
| 263 | - | } | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | func TestCopyModeRenderLineCursorMarker(t *testing.T) { | |
| 267 | - | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 3) | |
| 268 | - | m := &Mode{ViewportTop: 0, Cursor: 1, Anchor: -1} | |
| 269 | - | ||
| 270 | - | frame := m.Render(s) | |
| 271 | - | if !frame.At(0, 1).Style.Inverse { | |
| 272 | - | t.Fatalf("cursor line first cell inverse = false, want true") | |
| 273 | - | } | |
| 274 | - | if frame.At(1, 1).Style.Inverse { | |
| 275 | - | t.Fatalf("cursor line second cell inverse = true, want marker on first cell only") | |
| 276 | - | } | |
| 277 | - | if frame.At(0, 0).Style.Inverse || frame.At(0, 2).Style.Inverse { | |
| 278 | - | t.Fatalf("non-cursor lines have inverse marker, want none") | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | m.StartSelectionAt(s, 1) | |
| 282 | - | frame = m.Render(s) | |
| 283 | - | for x, c := range frame.Row(1) { | |
| 284 | - | if !c.Style.Inverse { | |
| 285 | - | t.Fatalf("selected cursor row cell %d inverse = false, want full-line selection highlight", x) | |
| 286 | - | } | |
| 287 | - | } | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) { | |
| 291 | - | // A cell that was already rendered inverse by the child program (e.g. its | |
| 292 | - | // own reverse-video attribute) must stay visibly selected: the fallback | |
| 293 | - | // selection style is a solid "force inverse", not an XOR toggle that would | |
| 294 | - | // flip it back to non-inverse and hide the highlight. | |
| 295 | - | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | |
| 296 | - | rows[0][0].Style.Inverse = true | |
| 297 | - | s := NewSnapshotFromRows(rows, 16, 2) | |
| 298 | - | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 299 | - | ||
| 300 | - | frame := m.Render(s) | |
| 301 | - | ||
| 302 | - | if !frame.At(0, 0).Style.Inverse { | |
| 303 | - | t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true") | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) { | |
| 308 | - | s := snapshot([]string{"alpha ", "beta "}, 2) | |
| 309 | - | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 310 | - | selection := renderer.DefaultStyle() | |
| 311 | - | selection.HasBackgroundRGB = true | |
| 312 | - | selection.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |
| 313 | - | ||
| 314 | - | frame := m.Render(s, renderer.DefaultStyle(), selection) | |
| 315 | - | ||
| 316 | - | got := frame.At(0, 0).Style | |
| 317 | - | if got.Inverse || !got.HasBackgroundRGB || got.BackgroundRGB != selection.BackgroundRGB { | |
| 318 | - | t.Fatalf("selected cell style = %+v, want themed selection %+v", got, selection) | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | func frameText(row []renderer.Cell) string { | |
| 323 | - | var b strings.Builder | |
| 324 | - | for _, c := range row { | |
| 325 | - | if c.Rune == 0 { | |
| 326 | - | b.WriteRune(' ') | |
| 327 | - | continue | |
| 328 | - | } | |
| 329 | - | b.WriteRune(c.Rune) | |
| 330 | - | } | |
| 331 | - | return b.String() | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | func TestCopyModeSearchMovesAndCyclesMatches(t *testing.T) { | |
| 335 | - | s := snapshot([]string{"alpha", "beta alpha", "gamma", "alpha omega"}, 2) | |
| 336 | - | m := NewMode(s) | |
| 337 | - | ||
| 338 | - | require.True(t, m.Search(s, "alpha")) | |
| 339 | - | require.Equal(t, 0, m.Cursor) | |
| 340 | - | require.Equal(t, 0, m.ViewportTop) | |
| 341 | - | ||
| 342 | - | require.True(t, m.NextSearchMatch(s, 1)) | |
| 343 | - | require.Equal(t, 1, m.Cursor) | |
| 344 | - | require.Equal(t, 0, m.ViewportTop) | |
| 345 | - | ||
| 346 | - | require.True(t, m.NextSearchMatch(s, 1)) | |
| 347 | - | require.Equal(t, 3, m.Cursor) | |
| 348 | - | require.Equal(t, 2, m.ViewportTop) | |
| 349 | - | ||
| 350 | - | require.True(t, m.NextSearchMatch(s, 1)) | |
| 351 | - | require.Equal(t, 0, m.Cursor) | |
| 352 | - | ||
| 353 | - | require.True(t, m.NextSearchMatch(s, -1)) | |
| 354 | - | require.Equal(t, 3, m.Cursor) | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | func TestFindMatchesReturnsNonOverlappingRepeatedMatches(t *testing.T) { | |
| 358 | - | s := snapshot([]string{"aaa"}, 1) | |
| 359 | - | ||
| 360 | - | matches := FindMatches(s, "aa") | |
| 361 | - | ||
| 362 | - | require.Len(t, matches, 1) | |
| 363 | - | require.Equal(t, 0, matches[0].Start) | |
| 364 | - | require.Equal(t, 2, matches[0].End) | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | func TestFindMatchesKeepsRuneAndCellIndexesAlignedWhenLowercasing(t *testing.T) { | |
| 368 | - | s := snapshot([]string{"İa"}, 1) | |
| 369 | - | ||
| 370 | - | matches := FindMatches(s, "İa") | |
| 371 | - | ||
| 372 | - | require.Len(t, matches, 1) | |
| 373 | - | require.Equal(t, 0, matches[0].Start) | |
| 374 | - | require.Equal(t, 2, matches[0].End) | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | func TestFindMatchesDoesNotCopyEveryHistoryRow(t *testing.T) { | |
| 378 | - | const rows = 100 | |
| 379 | - | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows, ChunkRows: 10}) | |
| 174 | + | func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |
| 175 | + | const rows = 10_000 | |
| 176 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256}) | |
| 380 | 177 | for range rows { | |
| 381 | - | history.Append(row(strings.Repeat("x", 120))) | |
| 382 | - | } | |
| 383 | - | s := NewSnapshot(history, renderer.NewFrame(120, 1)) | |
| 384 | - | ||
| 385 | - | allocs := testing.AllocsPerRun(10, func() { | |
| 386 | - | if matches := FindMatches(s, "missing"); len(matches) != 0 { | |
| 387 | - | t.Fatalf("unexpected matches: %d", len(matches)) | |
| 388 | - | } | |
| 389 | - | }) | |
| 390 | - | if allocs >= 250 { | |
| 391 | - | t.Fatalf("FindMatches allocations = %.0f, want < 250 (no row copy per history row)", allocs) | |
| 392 | - | } | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | |
| 396 | - | row := []renderer.Cell{ | |
| 397 | - | {Rune: '界'}, | |
| 398 | - | {Continuation: true}, | |
| 399 | - | {Rune: ' '}, | |
| 400 | - | {Rune: 'a'}, | |
| 401 | - | {Rune: 'l'}, | |
| 402 | - | {Rune: 'p'}, | |
| 403 | - | {Rune: 'h'}, | |
| 404 | - | {Rune: 'a'}, | |
| 405 | - | } | |
| 406 | - | s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1) | |
| 407 | - | ||
| 408 | - | wideMatches := FindMatches(s, "界") | |
| 409 | - | require.Len(t, wideMatches, 1) | |
| 410 | - | require.Equal(t, 0, wideMatches[0].Start) | |
| 411 | - | require.Equal(t, 2, wideMatches[0].End) | |
| 412 | - | ||
| 413 | - | matches := FindMatches(s, "alpha") | |
| 414 | - | require.Len(t, matches, 1) | |
| 415 | - | require.Equal(t, 3, matches[0].Start) | |
| 416 | - | require.Equal(t, 8, matches[0].End) | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | func TestCopyModeRenderHighlightsCurrentSearchMatch(t *testing.T) { | |
| 420 | - | s := snapshot([]string{"alpha", "beta alpha"}, 2) | |
| 421 | - | s.Width = 32 | |
| 422 | - | m := NewMode(s) | |
| 423 | - | require.True(t, m.Search(s, "alpha")) | |
| 424 | - | ||
| 425 | - | frame := m.Render(s) | |
| 426 | - | ||
| 427 | - | for x := range len("alpha") { | |
| 428 | - | if !frame.At(x, 0).Style.Inverse { | |
| 429 | - | t.Fatalf("search match cell %d inverse = false, want highlighted", x) | |
| 430 | - | } | |
| 431 | - | } | |
| 432 | - | if !strings.Contains(frameText(frame.Row(s.Height)), "/alpha") { | |
| 433 | - | t.Fatalf("status row = %q, want search query", frameText(frame.Row(s.Height))) | |
| 434 | - | } | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) { | |
| 438 | - | s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3) | |
| 439 | - | m := NewMode(s) | |
| 440 | - | m.Move(s, -2) | |
| 441 | - | m.ToggleSelection() | |
| 442 | - | anchor := m.Anchor | |
| 443 | - | ||
| 444 | - | m.Move(s, 3) | |
| 445 | - | if m.Anchor != anchor { | |
| 446 | - | t.Fatalf("Anchor after Move(+3) while selecting = %d, want %d", m.Anchor, anchor) | |
| 447 | - | } | |
| 448 | - | lo, hi, ok := m.SelectedBounds() | |
| 449 | - | if !ok || lo != anchor || hi != m.Cursor { | |
| 450 | - | t.Fatalf("SelectedBounds after Move(+3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, anchor, m.Cursor) | |
| 178 | + | history.Append(row("unmatched")) | |
| 451 | 179 | } | |
| 180 | + | history.Append(row("target")) | |
| 181 | + | snapshot := NewSnapshot(history, renderer.NewFrame(16, 1)) | |
| 182 | + | view := history.SealAndView() | |
| 183 | + | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | |
| 452 | 184 | ||
| 453 | - | m.Move(s, -3) | |
| 454 | - | if m.Anchor != anchor { | |
| 455 | - | t.Fatalf("Anchor after Move(-3) while selecting = %d, want %d", m.Anchor, anchor) | |
| 456 | - | } | |
| 457 | - | lo, hi, ok = m.SelectedBounds() | |
| 458 | - | if !ok || lo != m.Cursor || hi != anchor { | |
| 459 | - | t.Fatalf("SelectedBounds after Move(-3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, m.Cursor, anchor) | |
| 460 | - | } | |
| 461 | - | } | |
| 462 | - | ||
| 463 | - | func TestOSC52Base64Encoding(t *testing.T) { | |
| 464 | - | tests := []struct { | |
| 465 | - | name string | |
| 466 | - | text string | |
| 467 | - | }{ | |
| 468 | - | {name: "small payload", text: "hello\nworld"}, | |
| 469 | - | {name: "maximum payload", text: strings.Repeat("x", OSC52MaxPayloadBytes)}, | |
| 470 | - | } | |
| 471 | - | for _, tt := range tests { | |
| 472 | - | t.Run(tt.name, func(t *testing.T) { | |
| 473 | - | chunks := OSC52(tt.text) | |
| 474 | - | if len(chunks) != 1 { | |
| 475 | - | t.Fatalf("len(chunks) = %d, want 1", len(chunks)) | |
| 476 | - | } | |
| 477 | - | for _, chunk := range chunks { | |
| 478 | - | decoded := decodeOSC52Chunk(t, chunk) | |
| 479 | - | if string(decoded) != tt.text { | |
| 480 | - | t.Fatalf("decoded = %q, want %q", string(decoded), tt.text) | |
| 481 | - | } | |
| 482 | - | } | |
| 483 | - | }) | |
| 484 | - | } | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | func TestOSC52OversizedPayloadIsDeferred(t *testing.T) { | |
| 488 | - | chunks := OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)) | |
| 489 | - | if len(chunks) != 0 { | |
| 490 | - | t.Fatalf("len(chunks) = %d, want 0 to avoid corrupt multi-sequence clipboard replacement", len(chunks)) | |
| 491 | - | } | |
| 185 | + | doc := NewDocument(snapshot, "") | |
| 186 | + | require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target")) | |
| 492 | 187 | } | |
| 493 | 188 | ||
| 494 | - | func TestOSC52FromBase64(t *testing.T) { | |
| 495 | - | valid := base64.StdEncoding.EncodeToString([]byte("hello")) | |
| 496 | - | oversized := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", OSC52MaxPayloadBytes+1))) | |
| 497 | - | ||
| 498 | - | tests := []struct { | |
| 499 | - | name string | |
| 500 | - | in string | |
| 501 | - | want []byte | |
| 502 | - | }{ | |
| 503 | - | {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")}, | |
| 504 | - | {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")}, | |
| 505 | - | {name: "invalid base64", in: "not-valid-base64!!", want: nil}, | |
| 506 | - | {name: "oversized decoded payload", in: oversized, want: nil}, | |
| 507 | - | } | |
| 508 | - | for _, tt := range tests { | |
| 509 | - | t.Run(tt.name, func(t *testing.T) { | |
| 510 | - | got := OSC52FromBase64(tt.in) | |
| 511 | - | if !bytes.Equal(got, tt.want) { | |
| 512 | - | t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want) | |
| 513 | - | } | |
| 514 | - | }) | |
| 515 | - | } | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |
| 519 | - | history := newHistory(1) | |
| 520 | - | history.Append(row("old")) | |
| 521 | - | screen := renderer.NewFrame(3, 1) | |
| 522 | - | copy(screen.Row(0), row("one")) | |
| 523 | - | ||
| 524 | - | document := NewSnapshot(history, screen) | |
| 525 | - | history.Append(row("new")) | |
| 526 | - | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |
| 527 | - | ||
| 528 | - | require.Equal(t, 2, document.Len()) | |
| 529 | - | require.Equal(t, "old", rowText(document.Row(0))) | |
| 530 | - | require.Equal(t, "one", rowText(document.Row(1))) | |
| 531 | - | require.Nil(t, document.Row(-1)) | |
| 532 | - | require.Nil(t, document.Row(2)) | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |
| 536 | - | rows := [][]renderer.Cell{row("one")} | |
| 537 | - | document := NewSnapshotFromRows(rows, 3, 1) | |
| 538 | - | rows[0][0].Rune = 'x' | |
| 539 | - | require.Equal(t, "one", rowText(document.Row(0))) | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | func TestNewSnapshotFromRowsKeepsDocumentAndViewportDimensionsIndependent(t *testing.T) { | |
| 543 | - | document := NewSnapshotFromRows([][]renderer.Cell{row("one"), row("two")}, 7, 5) | |
| 544 | - | ||
| 545 | - | require.Equal(t, 2, document.Len()) | |
| 546 | - | require.Equal(t, 7, document.Width) | |
| 547 | - | require.Equal(t, 5, document.Height) | |
| 548 | - | require.Equal(t, "one", rowText(document.Row(0))) | |
| 549 | - | require.Equal(t, "two", rowText(document.Row(1))) | |
| 550 | - | require.Nil(t, document.Row(2)) | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | // This scaling gate deliberately compares equal row counts rather than an | |
| 554 | - | // absolute budget, which varies between Go releases. A HistoryView shares | |
| 555 | - | // sealed chunks, so its allocation must not grow with history-row width. | |
| 556 | - | func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) { | |
| 557 | - | if testing.Short() { | |
| 558 | - | t.Skip("skipping benchmark-driven allocation gate in short mode") | |
| 189 | + | func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) { | |
| 190 | + | const ( | |
| 191 | + | rows = 10_000 | |
| 192 | + | height = 4 | |
| 193 | + | maxAllocs = 20 | |
| 194 | + | ) | |
| 195 | + | lines := make([][]renderer.Cell, rows) | |
| 196 | + | for i := range lines { | |
| 197 | + | lines[i] = row("selection") | |
| 559 | 198 | } | |
| 560 | - | narrow := benchmarkNewSnapshotBytes(16) | |
| 561 | - | wide := benchmarkNewSnapshotBytes(512) | |
| 562 | - | const conservativeWidthTolerance = 2 | |
| 563 | - | require.LessOrEqual(t, wide, narrow*conservativeWidthTolerance, | |
| 564 | - | "NewSnapshot B/op scaled with row width: narrow=%d wide=%d", narrow, wide) | |
| 565 | - | } | |
| 199 | + | m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), "")) | |
| 200 | + | require.True(t, m.StartCharacterSelection(Pos{})) | |
| 201 | + | require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8})) | |
| 566 | 202 | ||
| 567 | - | func benchmarkNewSnapshotBytes(width int) int64 { | |
| 568 | - | const historyRows = 256 | |
| 569 | - | history := newHistory(historyRows) | |
| 570 | - | for range historyRows { | |
| 571 | - | history.Append(make([]renderer.Cell, width)) | |
| 572 | - | } | |
| 573 | - | // Keep visible-frame work constant so the comparison isolates history rows. | |
| 574 | - | screen := renderer.NewFrame(8, 4) | |
| 575 | - | result := testing.Benchmark(func(b *testing.B) { | |
| 576 | - | b.ReportAllocs() | |
| 577 | - | b.ResetTimer() | |
| 578 | - | for b.Loop() { | |
| 579 | - | benchmarkSnapshotSink = NewSnapshot(history, screen) | |
| 580 | - | } | |
| 203 | + | allocs := testing.AllocsPerRun(10, func() { | |
| 204 | + | _ = m.Render() | |
| 581 | 205 | }) | |
| 582 | - | return result.AllocedBytesPerOp() | |
| 206 | + | require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height) | |
| 583 | 207 | } | |
| 584 | 208 | ||
| 585 | - | // This regression replaces the removed Scrollback ring tests: the VT history | |
| 586 | - | // remains bounded oldest-first and a captured copy document survives eviction. | |
| 587 | - | func TestNewSnapshotRetainsBoundedHistoryAcrossLaterEviction(t *testing.T) { | |
| 588 | - | history := newHistory(2) | |
| 589 | - | history.Append(row("one")) | |
| 590 | - | history.Append(row("two")) | |
| 591 | - | document := NewSnapshot(history, renderer.NewFrame(3, 1)) | |
| 592 | - | history.Append(row("three")) | |
| 593 | - | history.Append(row("four")) | |
| 594 | - | ||
| 595 | - | // A copy document is history followed by the visible frame. Its total is | |
| 596 | - | // therefore the bounded retained history plus the one visible row. | |
| 597 | - | require.Equal(t, 3, document.Len()) | |
| 598 | - | require.Equal(t, "one", rowText(document.Row(0))) | |
| 599 | - | require.Equal(t, "two", rowText(document.Row(1))) | |
| 600 | - | require.Equal(t, " ", rowText(document.Row(2)), "the visible frame remains part of the document") | |
| 601 | - | view := history.View() | |
| 602 | - | require.Equal(t, "three", rowText(view.Row(0))) | |
| 603 | - | require.Equal(t, "four", rowText(view.Row(1))) | |
| 604 | - | } | |
| 605 | - | ||
| 606 | - | func BenchmarkNewSnapshot10KRows(b *testing.B) { | |
| 209 | + | func BenchmarkCopyModeRenderLargeSelection(b *testing.B) { | |
| 607 | 210 | const ( | |
| 608 | - | width = 120 | |
| 609 | - | height = 40 | |
| 610 | - | historyRows = 10_000 | |
| 211 | + | rows = 10_000 | |
| 212 | + | height = 4 | |
| 611 | 213 | ) | |
| 612 | - | ||
| 613 | - | history := newHistory(historyRows) | |
| 614 | - | for range historyRows { | |
| 615 | - | history.Append(row(strings.Repeat("x", width))) | |
| 214 | + | lines := make([][]renderer.Cell, rows) | |
| 215 | + | for i := range lines { | |
| 216 | + | lines[i] = row("selection") | |
| 616 | 217 | } | |
| 617 | - | screen := renderer.NewFrame(width, height) | |
| 618 | - | for y := range height { | |
| 619 | - | copy(screen.Row(y), row(strings.Repeat("s", width))) | |
| 218 | + | m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), "")) | |
| 219 | + | if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) { | |
| 220 | + | b.Fatal("could not create large selection") | |
| 620 | 221 | } | |
| 621 | 222 | ||
| 622 | 223 | b.ReportAllocs() | |
| 623 | 224 | b.ResetTimer() | |
| 624 | - | for b.Loop() { | |
| 625 | - | benchmarkSnapshotSink = NewSnapshot(history, screen) | |
| 225 | + | for range b.N { | |
| 226 | + | _ = m.Render() | |
| 626 | 227 | } | |
| 627 | 228 | } | |
| 628 | 229 | ||
| 629 | - | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | |
| 630 | - | t.Helper() | |
| 631 | - | s := string(chunk) | |
| 632 | - | if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") { | |
| 633 | - | t.Fatalf("chunk %q missing OSC52 wrapper", s) | |
| 634 | - | } | |
| 635 | - | encoded := strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07") | |
| 636 | - | decoded, err := base64.StdEncoding.DecodeString(encoded) | |
| 637 | - | if err != nil { | |
| 638 | - | t.Fatalf("DecodeString(%q) error = %v", encoded, err) | |
| 230 | + | func TestCopyModeOSC52SelectionMatrix(t *testing.T) { | |
| 231 | + | cases := []struct { | |
| 232 | + | name string | |
| 233 | + | selectFn func(*Mode) | |
| 234 | + | want string | |
| 235 | + | }{ | |
| 236 | + | {"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"}, | |
| 237 | + | {"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"}, | |
| 238 | + | {"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"}, | |
| 239 | + | {"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"}, | |
| 240 | + | {"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"}, | |
| 241 | + | } | |
| 242 | + | for _, tc := range cases { | |
| 243 | + | t.Run(tc.name, func(t *testing.T) { | |
| 244 | + | m := modeFor([]string{"alpha", "bravo"}, 2) | |
| 245 | + | tc.selectFn(m) | |
| 246 | + | got := OSC52(m.SelectedText()) | |
| 247 | + | require.Len(t, got, 1) | |
| 248 | + | want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07") | |
| 249 | + | require.True(t, bytes.Equal(want, got[0])) | |
| 250 | + | }) | |
| 639 | 251 | } | |
| 640 | - | return decoded | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestOSC52LimitsAndBase64(t *testing.T) { | |
| 255 | + | require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1))) | |
| 256 | + | encoded := base64.StdEncoding.EncodeToString([]byte("hello")) | |
| 257 | + | require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded)) | |
| 641 | 258 | } | |
A internal/usecase/copy/navigator.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | // Navigator owns cursor movement policy over a Document. Its positions are | |
| 4 | + | // always normalized glyph heads after a successful movement. | |
| 5 | + | type Navigator struct { | |
| 6 | + | Pos Pos | |
| 7 | + | PreferredCol int | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // NewNavigator creates a navigator at at. Set can normalize a position when a | |
| 11 | + | // document is available. | |
| 12 | + | func NewNavigator(at Pos) Navigator { | |
| 13 | + | return Navigator{Pos: at, PreferredCol: at.Col} | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Left moves to the previous glyph on the current physical row. | |
| 17 | + | func (n *Navigator) Left(doc *Document) bool { | |
| 18 | + | if doc == nil { | |
| 19 | + | return false | |
| 20 | + | } | |
| 21 | + | pos, ok := doc.PrevGlyph(n.Pos) | |
| 22 | + | if !ok { | |
| 23 | + | return false | |
| 24 | + | } | |
| 25 | + | return n.set(pos, true) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Right moves to the next glyph on the current physical row. | |
| 29 | + | func (n *Navigator) Right(doc *Document) bool { | |
| 30 | + | if doc == nil { | |
| 31 | + | return false | |
| 32 | + | } | |
| 33 | + | pos, ok := doc.NextGlyph(n.Pos) | |
| 34 | + | if !ok { | |
| 35 | + | return false | |
| 36 | + | } | |
| 37 | + | return n.set(pos, true) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Up moves to the preceding physical row while retaining PreferredCol. | |
| 41 | + | func (n *Navigator) Up(doc *Document) bool { | |
| 42 | + | if doc == nil { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | return n.moveVertical(doc, n.Pos.Row-1) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Down moves to the following physical row while retaining PreferredCol. | |
| 49 | + | func (n *Navigator) Down(doc *Document) bool { | |
| 50 | + | if doc == nil { | |
| 51 | + | return false | |
| 52 | + | } | |
| 53 | + | return n.moveVertical(doc, n.Pos.Row+1) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // WordNext moves to the start of the next word, possibly on another row. | |
| 57 | + | func (n *Navigator) WordNext(doc *Document) bool { | |
| 58 | + | if doc == nil { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | pos, ok := doc.NextWordStart(n.Pos) | |
| 62 | + | if !ok { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | return n.set(pos, true) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // WordBackward moves to the start of the current or preceding word. | |
| 69 | + | func (n *Navigator) WordBackward(doc *Document) bool { | |
| 70 | + | if doc == nil { | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | pos, ok := doc.PreviousWordStart(n.Pos) | |
| 74 | + | if !ok { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | return n.set(pos, true) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // WordEnd moves to the end of the current or next word. | |
| 81 | + | func (n *Navigator) WordEnd(doc *Document) bool { | |
| 82 | + | if doc == nil { | |
| 83 | + | return false | |
| 84 | + | } | |
| 85 | + | pos, ok := doc.NextWordEnd(n.Pos) | |
| 86 | + | if !ok { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | return n.set(pos, true) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Top moves to the first physical row at the current column and resets the | |
| 93 | + | // preferred column to the normalized destination. | |
| 94 | + | func (n *Navigator) Top(doc *Document) bool { | |
| 95 | + | if doc == nil || doc.Len() == 0 { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | return n.Set(doc, Pos{Row: 0, Col: n.Pos.Col}) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Bottom moves to the last physical row at the current column and resets the | |
| 102 | + | // preferred column to the normalized destination. | |
| 103 | + | func (n *Navigator) Bottom(doc *Document) bool { | |
| 104 | + | if doc == nil || doc.Len() == 0 { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | return n.Set(doc, Pos{Row: doc.Len() - 1, Col: n.Pos.Col}) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Page moves rows physical rows while retaining PreferredCol. Its destination | |
| 111 | + | // clamps to the first or last physical row. | |
| 112 | + | func (n *Navigator) Page(doc *Document, rows int) bool { | |
| 113 | + | if doc == nil || doc.Len() == 0 { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | return n.moveVertical(doc, min(max(n.Pos.Row+rows, 0), doc.Len()-1)) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Set moves to pos after normalizing it through doc and updates PreferredCol. | |
| 120 | + | func (n *Navigator) Set(doc *Document, pos Pos) bool { | |
| 121 | + | if doc == nil { | |
| 122 | + | return false | |
| 123 | + | } | |
| 124 | + | pos, ok := doc.Normalize(pos) | |
| 125 | + | if !ok { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | return n.set(pos, true) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (n *Navigator) moveVertical(doc *Document, row int) bool { | |
| 132 | + | if row < 0 || row >= doc.Len() { | |
| 133 | + | return false | |
| 134 | + | } | |
| 135 | + | pos, ok := navigatorPosOnRow(doc, row, n.PreferredCol) | |
| 136 | + | if !ok { | |
| 137 | + | return false | |
| 138 | + | } | |
| 139 | + | return n.set(pos, false) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func navigatorPosOnRow(doc *Document, row, col int) (Pos, bool) { | |
| 143 | + | cells := doc.Row(row) | |
| 144 | + | if len(cells) == 0 { | |
| 145 | + | return doc.Normalize(Pos{Row: row, Col: 0}) | |
| 146 | + | } | |
| 147 | + | col = min(max(col, 0), len(cells)-1) | |
| 148 | + | return doc.Normalize(Pos{Row: row, Col: col}) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (n *Navigator) set(pos Pos, setPreferred bool) bool { | |
| 152 | + | moved := n.Pos != pos | |
| 153 | + | n.Pos = pos | |
| 154 | + | if setPreferred { | |
| 155 | + | n.PreferredCol = pos.Col | |
| 156 | + | } | |
| 157 | + | return moved | |
| 158 | + | } | |
A internal/usecase/copy/navigator_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func navigatorDocument() *Document { | |
| 11 | + | return NewDocument(NewSnapshotFromRows([][]renderer.Cell{ | |
| 12 | + | documentCells("abcdef"), | |
| 13 | + | documentCells("xy"), | |
| 14 | + | {{Rune: '界'}, {Continuation: true}, {Rune: 'z'}}, | |
| 15 | + | {}, | |
| 16 | + | documentCells("omega"), | |
| 17 | + | }, 6, 2), " -_@") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestNavigatorHorizontalSkipsContinuations(t *testing.T) { | |
| 21 | + | doc := navigatorDocument() | |
| 22 | + | ||
| 23 | + | tests := []struct { | |
| 24 | + | name string | |
| 25 | + | move func(*Navigator, *Document) bool | |
| 26 | + | from Pos | |
| 27 | + | want Pos | |
| 28 | + | }{ | |
| 29 | + | {"right skips wide continuation", (*Navigator).Right, Pos{Row: 2, Col: 0}, Pos{Row: 2, Col: 2}}, | |
| 30 | + | {"left skips wide continuation", (*Navigator).Left, Pos{Row: 2, Col: 2}, Pos{Row: 2, Col: 0}}, | |
| 31 | + | } | |
| 32 | + | for _, tt := range tests { | |
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |
| 34 | + | navigator := NewNavigator(tt.from) | |
| 35 | + | ||
| 36 | + | require.True(t, tt.move(&navigator, doc)) | |
| 37 | + | require.Equal(t, tt.want, navigator.Pos) | |
| 38 | + | require.Equal(t, tt.want.Col, navigator.PreferredCol) | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestNavigatorVerticalRetainsPreferredColumn(t *testing.T) { | |
| 44 | + | doc := navigatorDocument() | |
| 45 | + | navigator := NewNavigator(Pos{Row: 0, Col: 5}) | |
| 46 | + | ||
| 47 | + | require.True(t, navigator.Down(doc)) | |
| 48 | + | require.Equal(t, Pos{Row: 1, Col: 1}, navigator.Pos) | |
| 49 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 50 | + | ||
| 51 | + | require.True(t, navigator.Down(doc)) | |
| 52 | + | require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos) | |
| 53 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 54 | + | ||
| 55 | + | require.True(t, navigator.Down(doc)) | |
| 56 | + | require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos) | |
| 57 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 58 | + | ||
| 59 | + | require.True(t, navigator.Down(doc)) | |
| 60 | + | require.Equal(t, Pos{Row: 4, Col: 4}, navigator.Pos) | |
| 61 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 62 | + | ||
| 63 | + | require.True(t, navigator.Up(doc)) | |
| 64 | + | require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos) | |
| 65 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 66 | + | ||
| 67 | + | require.True(t, navigator.Up(doc)) | |
| 68 | + | require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos) | |
| 69 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestNavigatorWordMotionsCrossRows(t *testing.T) { | |
| 73 | + | doc := navigatorDocument() | |
| 74 | + | ||
| 75 | + | tests := []struct { | |
| 76 | + | name string | |
| 77 | + | move func(*Navigator, *Document) bool | |
| 78 | + | from Pos | |
| 79 | + | want Pos | |
| 80 | + | }{ | |
| 81 | + | {"word next", (*Navigator).WordNext, Pos{Row: 0, Col: 5}, Pos{Row: 1, Col: 0}}, | |
| 82 | + | {"word backward", (*Navigator).WordBackward, Pos{Row: 1, Col: 0}, Pos{Row: 0, Col: 0}}, | |
| 83 | + | {"word end", (*Navigator).WordEnd, Pos{Row: 1, Col: 1}, Pos{Row: 2, Col: 2}}, | |
| 84 | + | } | |
| 85 | + | for _, tt := range tests { | |
| 86 | + | t.Run(tt.name, func(t *testing.T) { | |
| 87 | + | navigator := NewNavigator(tt.from) | |
| 88 | + | ||
| 89 | + | require.True(t, tt.move(&navigator, doc)) | |
| 90 | + | require.Equal(t, tt.want, navigator.Pos) | |
| 91 | + | require.Equal(t, tt.want.Col, navigator.PreferredCol) | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestNavigatorEmptyTopBottomPageAndSet(t *testing.T) { | |
| 97 | + | doc := navigatorDocument() | |
| 98 | + | ||
| 99 | + | t.Run("empty row is stable", func(t *testing.T) { | |
| 100 | + | navigator := NewNavigator(Pos{Row: 2, Col: 2}) | |
| 101 | + | require.True(t, navigator.Down(doc)) | |
| 102 | + | require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos) | |
| 103 | + | require.False(t, navigator.Right(doc)) | |
| 104 | + | require.Equal(t, Pos{Row: 3, Col: 0}, navigator.Pos) | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | t.Run("top and bottom reset the preferred column", func(t *testing.T) { | |
| 108 | + | navigator := NewNavigator(Pos{Row: 0, Col: 5}) | |
| 109 | + | require.True(t, navigator.Down(doc)) | |
| 110 | + | require.Equal(t, Pos{Row: 1, Col: 1}, navigator.Pos) | |
| 111 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 112 | + | require.True(t, navigator.Top(doc)) | |
| 113 | + | require.Equal(t, Pos{Row: 0, Col: 1}, navigator.Pos) | |
| 114 | + | require.Equal(t, 1, navigator.PreferredCol) | |
| 115 | + | require.True(t, navigator.Bottom(doc)) | |
| 116 | + | require.Equal(t, Pos{Row: 4, Col: 1}, navigator.Pos) | |
| 117 | + | require.Equal(t, 1, navigator.PreferredCol) | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | t.Run("page keeps preferred column", func(t *testing.T) { | |
| 121 | + | navigator := NewNavigator(Pos{Row: 0, Col: 5}) | |
| 122 | + | require.True(t, navigator.Page(doc, 2)) | |
| 123 | + | require.Equal(t, Pos{Row: 2, Col: 2}, navigator.Pos) | |
| 124 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 125 | + | require.True(t, navigator.Page(doc, 2)) | |
| 126 | + | require.Equal(t, Pos{Row: 4, Col: 4}, navigator.Pos) | |
| 127 | + | require.Equal(t, 5, navigator.PreferredCol) | |
| 128 | + | }) | |
| 129 | + | ||
| 130 | + | t.Run("set normalizes continuation and updates preferred column", func(t *testing.T) { | |
| 131 | + | navigator := NewNavigator(Pos{Row: 0, Col: 0}) | |
| 132 | + | require.True(t, navigator.Set(doc, Pos{Row: 2, Col: 1})) | |
| 133 | + | require.Equal(t, Pos{Row: 2, Col: 0}, navigator.Pos) | |
| 134 | + | require.Equal(t, 0, navigator.PreferredCol) | |
| 135 | + | require.False(t, navigator.Set(doc, Pos{Row: 9, Col: 0})) | |
| 136 | + | require.Equal(t, Pos{Row: 2, Col: 0}, navigator.Pos) | |
| 137 | + | }) | |
| 138 | + | } | |
A internal/usecase/copy/selection.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // Granularity controls how a selection's endpoints become display-cell ranges. | |
| 6 | + | type Granularity uint8 | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | Line Granularity = iota | |
| 10 | + | Character | |
| 11 | + | Word | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Selection is an immutable-document selection. Anchor and Active are glyph | |
| 15 | + | // head positions whenever they originate from a Document operation. | |
| 16 | + | type Selection struct { | |
| 17 | + | Anchor Pos | |
| 18 | + | Active Pos | |
| 19 | + | Granularity Granularity | |
| 20 | + | Enabled bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // NewLineSelection starts a complete physical-line selection at at. | |
| 24 | + | func NewLineSelection(at Pos) Selection { | |
| 25 | + | return Selection{Anchor: at, Active: at, Granularity: Line, Enabled: true} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewWordSelection selects the complete word containing at. Separators cannot | |
| 29 | + | // begin a word selection. | |
| 30 | + | func NewWordSelection(doc *Document, at Pos) (Selection, bool) { | |
| 31 | + | if doc == nil { | |
| 32 | + | return Selection{}, false | |
| 33 | + | } | |
| 34 | + | start, end, ok := doc.WordBounds(at) | |
| 35 | + | if !ok { | |
| 36 | + | return Selection{}, false | |
| 37 | + | } | |
| 38 | + | return Selection{Anchor: start, Active: end, Granularity: Word, Enabled: true}, true | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Ordered returns the selection endpoints in document order. | |
| 42 | + | func (s Selection) Ordered() (Pos, Pos) { | |
| 43 | + | if before(s.Active, s.Anchor) { | |
| 44 | + | return s.Active, s.Anchor | |
| 45 | + | } | |
| 46 | + | return s.Anchor, s.Active | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Ranges returns the canonical, inclusive per-physical-row display-cell | |
| 50 | + | // ranges. It returns nil for disabled selections or invalid endpoints. | |
| 51 | + | func (s Selection) Ranges(doc *Document) []CellRange { | |
| 52 | + | bounds, ok := s.bounds(doc) | |
| 53 | + | if !ok { | |
| 54 | + | return nil | |
| 55 | + | } | |
| 56 | + | ranges := make([]CellRange, 0, bounds.end.Row-bounds.start.Row+1) | |
| 57 | + | for row := bounds.start.Row; row <= bounds.end.Row; row++ { | |
| 58 | + | r, ok := bounds.rangeForRow(doc, row) | |
| 59 | + | if ok { | |
| 60 | + | ranges = append(ranges, r) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | return ranges | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // RangeForRow returns the canonical inclusive range for one document row. It | |
| 67 | + | // lets viewport renderers avoid materializing ranges for off-screen rows. | |
| 68 | + | func (s Selection) RangeForRow(doc *Document, row int) (CellRange, bool) { | |
| 69 | + | bounds, ok := s.bounds(doc) | |
| 70 | + | if !ok { | |
| 71 | + | return CellRange{}, false | |
| 72 | + | } | |
| 73 | + | return bounds.rangeForRow(doc, row) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | type selectionBounds struct { | |
| 77 | + | start, end Pos | |
| 78 | + | linewise bool | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (s Selection) bounds(doc *Document) (selectionBounds, bool) { | |
| 82 | + | if !s.Enabled || doc == nil { | |
| 83 | + | return selectionBounds{}, false | |
| 84 | + | } | |
| 85 | + | anchor, ok := doc.Normalize(s.Anchor) | |
| 86 | + | if !ok { | |
| 87 | + | return selectionBounds{}, false | |
| 88 | + | } | |
| 89 | + | active, ok := doc.Normalize(s.Active) | |
| 90 | + | if !ok { | |
| 91 | + | return selectionBounds{}, false | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | switch s.Granularity { | |
| 95 | + | case Line: | |
| 96 | + | start, end := order(anchor, active) | |
| 97 | + | return selectionBounds{start: start, end: end, linewise: true}, true | |
| 98 | + | case Character: | |
| 99 | + | start, end := order(anchor, active) | |
| 100 | + | return selectionBounds{start: start, end: end}, true | |
| 101 | + | case Word: | |
| 102 | + | anchorStart, anchorEnd, ok := doc.WordBounds(anchor) | |
| 103 | + | if !ok { | |
| 104 | + | return selectionBounds{}, false | |
| 105 | + | } | |
| 106 | + | activeStart, activeEnd, ok := doc.WordBounds(active) | |
| 107 | + | if !ok { | |
| 108 | + | return selectionBounds{}, false | |
| 109 | + | } | |
| 110 | + | start, _ := order(anchorStart, activeStart) | |
| 111 | + | _, end := order(anchorEnd, activeEnd) | |
| 112 | + | return selectionBounds{start: start, end: end}, true | |
| 113 | + | default: | |
| 114 | + | return selectionBounds{}, false | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (b selectionBounds) rangeForRow(doc *Document, row int) (CellRange, bool) { | |
| 119 | + | if row < b.start.Row || row > b.end.Row { | |
| 120 | + | return CellRange{}, false | |
| 121 | + | } | |
| 122 | + | cells := doc.Row(row) | |
| 123 | + | if len(cells) == 0 { | |
| 124 | + | return CellRange{Row: row}, true | |
| 125 | + | } | |
| 126 | + | if b.linewise { | |
| 127 | + | return CellRange{Row: row, End: len(cells) - 1}, true | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | start, end := 0, len(cells)-1 | |
| 131 | + | if row == b.start.Row { | |
| 132 | + | start = b.start.Col | |
| 133 | + | } | |
| 134 | + | if row == b.end.Row { | |
| 135 | + | end = glyphEnd(cells, b.end.Col) | |
| 136 | + | } | |
| 137 | + | return CellRange{Row: row, Start: start, End: end}, true | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Text extracts the selection using its range granularity's newline and | |
| 141 | + | // trailing-space semantics. | |
| 142 | + | func (s Selection) Text(doc *Document) string { | |
| 143 | + | if doc == nil { | |
| 144 | + | return "" | |
| 145 | + | } | |
| 146 | + | return doc.Extract(s.Ranges(doc), s.Granularity == Line) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Extend moves the active endpoint. Document normalization occurs when ranges | |
| 150 | + | // are requested so callers may pass raw pointer coordinates. | |
| 151 | + | func (s *Selection) Extend(at Pos) { | |
| 152 | + | s.Active = at | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // AsCharacter changes an active selection to stream selection without moving | |
| 156 | + | // either endpoint. | |
| 157 | + | func (s *Selection) AsCharacter() { | |
| 158 | + | s.Granularity = Character | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func before(left, right Pos) bool { | |
| 162 | + | return left.Row < right.Row || left.Row == right.Row && left.Col < right.Col | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func order(left, right Pos) (Pos, Pos) { | |
| 166 | + | if before(right, left) { | |
| 167 | + | return right, left | |
| 168 | + | } | |
| 169 | + | return left, right | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // glyphEnd expands a glyph-head endpoint over all of its continuation cells. | |
| 173 | + | func glyphEnd(cells []renderer.Cell, col int) int { | |
| 174 | + | for col+1 < len(cells) && cells[col+1].Continuation { | |
| 175 | + | col++ | |
| 176 | + | } | |
| 177 | + | return col | |
| 178 | + | } | |
A internal/usecase/copy/selection_test.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSelectionRanges(t *testing.T) { | |
| 11 | + | doc := documentFromStrings([]string{"alpha", "bravo", "charlie"}, " -_@") | |
| 12 | + | ||
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | selection Selection | |
| 16 | + | want []CellRange | |
| 17 | + | }{ | |
| 18 | + | { | |
| 19 | + | name: "line forward", | |
| 20 | + | selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Line, Enabled: true}, | |
| 21 | + | want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}}, | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | name: "line reverse", | |
| 25 | + | selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Line, Enabled: true}, | |
| 26 | + | want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}}, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "character forward", | |
| 30 | + | selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, | |
| 31 | + | want: []CellRange{{Row: 0, Start: 2, End: 4}, {Row: 1, Start: 0, End: 1}}, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "character reverse", | |
| 35 | + | selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Character, Enabled: true}, | |
| 36 | + | want: []CellRange{{Row: 0, Start: 2, End: 4}, {Row: 1, Start: 0, End: 1}}, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "word forward", | |
| 40 | + | selection: Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Word, Enabled: true}, | |
| 41 | + | want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "word reverse", | |
| 45 | + | selection: Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}, Granularity: Word, Enabled: true}, | |
| 46 | + | want: []CellRange{{Row: 0, Start: 0, End: 4}, {Row: 1, Start: 0, End: 4}}, | |
| 47 | + | }, | |
| 48 | + | {name: "disabled", selection: Selection{}, want: nil}, | |
| 49 | + | } | |
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |
| 52 | + | require.Equal(t, tt.want, tt.selection.Ranges(doc)) | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestSelectionRangeForRowMatchesCanonicalRanges(t *testing.T) { | |
| 58 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}} | |
| 59 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha"), wide, row("bravo")}, 5, 3), " -_@") | |
| 60 | + | tests := []struct { | |
| 61 | + | name string | |
| 62 | + | selection Selection | |
| 63 | + | row int | |
| 64 | + | want CellRange | |
| 65 | + | ok bool | |
| 66 | + | }{ | |
| 67 | + | {"line reverse", Selection{Anchor: Pos{2, 1}, Active: Pos{0, 2}, Granularity: Line, Enabled: true}, 1, CellRange{Row: 1, End: 3}, true}, | |
| 68 | + | {"character wide", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 1, CellRange{Row: 1, End: 2}, true}, | |
| 69 | + | {"word", Selection{Anchor: Pos{0, 2}, Active: Pos{2, 1}, Granularity: Word, Enabled: true}, 1, CellRange{Row: 1, End: 3}, true}, | |
| 70 | + | {"outside selection", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, 2, CellRange{}, false}, | |
| 71 | + | } | |
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | got, ok := tt.selection.RangeForRow(doc, tt.row) | |
| 75 | + | require.Equal(t, tt.ok, ok) | |
| 76 | + | if ok { | |
| 77 | + | require.Equal(t, tt.want, got) | |
| 78 | + | require.Contains(t, tt.selection.Ranges(doc), got) | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestSelectionOrdered(t *testing.T) { | |
| 85 | + | tests := []struct { | |
| 86 | + | name string | |
| 87 | + | selection Selection | |
| 88 | + | start Pos | |
| 89 | + | end Pos | |
| 90 | + | }{ | |
| 91 | + | {"forward", Selection{Anchor: Pos{0, 2}, Active: Pos{1, 1}}, Pos{0, 2}, Pos{1, 1}}, | |
| 92 | + | {"reverse", Selection{Anchor: Pos{1, 1}, Active: Pos{0, 2}}, Pos{0, 2}, Pos{1, 1}}, | |
| 93 | + | } | |
| 94 | + | for _, tt := range tests { | |
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |
| 96 | + | start, end := tt.selection.Ordered() | |
| 97 | + | require.Equal(t, tt.start, start) | |
| 98 | + | require.Equal(t, tt.end, end) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestSelectionRangesEmptyRowsAndWideGlyphs(t *testing.T) { | |
| 104 | + | wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}} | |
| 105 | + | doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{{}, wide, {}}, 4, 3), " -_@") | |
| 106 | + | ||
| 107 | + | tests := []struct { | |
| 108 | + | name string | |
| 109 | + | selection Selection | |
| 110 | + | want []CellRange | |
| 111 | + | }{ | |
| 112 | + | { | |
| 113 | + | name: "line includes empty rows", | |
| 114 | + | selection: Selection{Anchor: Pos{0, 0}, Active: Pos{2, 0}, Granularity: Line, Enabled: true}, | |
| 115 | + | want: []CellRange{{Row: 0, Start: 0, End: 0}, {Row: 1, Start: 0, End: 3}, {Row: 2, Start: 0, End: 0}}, | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "character expands wide glyph continuation", | |
| 119 | + | selection: Selection{Anchor: Pos{1, 1}, Active: Pos{1, 2}, Granularity: Character, Enabled: true}, | |
| 120 | + | want: []CellRange{{Row: 1, Start: 1, End: 2}}, | |
| 121 | + | }, | |
| 122 | + | { | |
| 123 | + | name: "character crosses empty row", | |
| 124 | + | selection: Selection{Anchor: Pos{0, 0}, Active: Pos{1, 1}, Granularity: Character, Enabled: true}, | |
| 125 | + | want: []CellRange{{Row: 0, Start: 0, End: 0}, {Row: 1, Start: 0, End: 2}}, | |
| 126 | + | }, | |
| 127 | + | } | |
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | require.Equal(t, tt.want, tt.selection.Ranges(doc)) | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestWordSelectionRejectsSeparator(t *testing.T) { | |
| 136 | + | doc := documentFromStrings([]string{"alpha/beta"}, "/") | |
| 137 | + | ||
| 138 | + | selection, ok := NewWordSelection(doc, Pos{0, 5}) | |
| 139 | + | require.False(t, ok) | |
| 140 | + | require.False(t, selection.Enabled) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestSelectionConstructorsAndMutators(t *testing.T) { | |
| 144 | + | doc := documentFromStrings([]string{"alpha", "bravo"}, " -_@") | |
| 145 | + | ||
| 146 | + | selection := NewLineSelection(Pos{0, 2}) | |
| 147 | + | require.True(t, selection.Enabled) | |
| 148 | + | require.Equal(t, Line, selection.Granularity) | |
| 149 | + | require.Equal(t, Pos{0, 2}, selection.Anchor) | |
| 150 | + | ||
| 151 | + | selection.Extend(Pos{1, 1}) | |
| 152 | + | selection.AsCharacter() | |
| 153 | + | require.Equal(t, Character, selection.Granularity) | |
| 154 | + | require.Equal(t, "pha\nbr", selection.Text(doc)) | |
| 155 | + | ||
| 156 | + | word, ok := NewWordSelection(doc, Pos{1, 2}) | |
| 157 | + | require.True(t, ok) | |
| 158 | + | require.Equal(t, "bravo", word.Text(doc)) | |
| 159 | + | word.Extend(Pos{0, 2}) | |
| 160 | + | require.Equal(t, "alpha\nbravo", word.Text(doc)) | |
| 161 | + | } | |
M internal/usecase/daemon/config.go +9 -0
| @@ -29,6 +29,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 29 | 29 | d.restoreProcessAllowlist.Store(&allowlist) | |
| 30 | 30 | floating := cfg.Floating | |
| 31 | 31 | d.floatingConfig.Store(&floating) | |
| 32 | + | copyConfig := cfg.Copy | |
| 33 | + | d.copyConfig.Store(©Config) | |
| 32 | 34 | palette := cfg.Palette | |
| 33 | 35 | d.paletteConfig.Store(&palette) | |
| 34 | 36 | tabs := cfg.Tabs | |
| @@ -182,6 +184,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) { | |||
| 182 | 184 | d.log.Warn("config warning", "msg", w.Msg) | |
| 183 | 185 | } | |
| 184 | 186 | ||
| 187 | + | func (d *Daemon) currentCopyConfig() domain.CopyConfig { | |
| 188 | + | if cfg := d.copyConfig.Load(); cfg != nil { | |
| 189 | + | return *cfg | |
| 190 | + | } | |
| 191 | + | return domain.Defaults().Copy | |
| 192 | + | } | |
| 193 | + | ||
| 185 | 194 | func (d *Daemon) currentPaletteConfig() domain.PaletteConfig { | |
| 186 | 195 | if cfg := d.paletteConfig.Load(); cfg != nil { | |
| 187 | 196 | return *cfg | |
M internal/usecase/daemon/config_test.go +45 -0
| @@ -50,6 +50,51 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 50 | 50 | require.Equal(t, "NT", cmd.Code) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | func TestApplyConfigPublishesCopyConfig(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | apply func(*Daemon) | |
| 57 | + | want domain.CopyConfig | |
| 58 | + | }{ | |
| 59 | + | { | |
| 60 | + | name: "default", | |
| 61 | + | want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "custom", | |
| 65 | + | apply: func(d *Daemon) { | |
| 66 | + | cfg := domain.Defaults() | |
| 67 | + | cfg.Copy = domain.CopyConfig{WordSeparators: "/:"} | |
| 68 | + | d.ApplyConfig(cfg) | |
| 69 | + | }, | |
| 70 | + | want: domain.CopyConfig{WordSeparators: "/:"}, | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "reload empty", | |
| 74 | + | apply: func(d *Daemon) { | |
| 75 | + | configured := domain.Defaults() | |
| 76 | + | configured.Copy = domain.CopyConfig{WordSeparators: "/:"} | |
| 77 | + | d.ApplyConfig(configured) | |
| 78 | + | ||
| 79 | + | reloaded := domain.Defaults() | |
| 80 | + | reloaded.Copy = domain.CopyConfig{} | |
| 81 | + | d.ApplyConfig(reloaded) | |
| 82 | + | }, | |
| 83 | + | want: domain.CopyConfig{}, | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | for _, tt := range tests { | |
| 88 | + | t.Run(tt.name, func(t *testing.T) { | |
| 89 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 90 | + | if tt.apply != nil { | |
| 91 | + | tt.apply(d) | |
| 92 | + | } | |
| 93 | + | require.Equal(t, tt.want, d.currentCopyConfig()) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 53 | 98 | func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) { | |
| 54 | 99 | d := newTestDaemon(t, nil, stubClock{}) | |
| 55 | 100 | require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig()) | |
A internal/usecase/daemon/copy_mouse_input.go +303 -0
| @@ -0,0 +1,303 @@ | |||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 2 | + | package daemon | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // handleCopyMouse owns copy-mode mouse routing. A release is lifecycle input | |
| 10 | + | // even while visual search owns all other copy-mode mouse input. | |
| 11 | + | func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) bool { | |
| 12 | + | rt := ac.overlays | |
| 13 | + | if !rt.copyActive() { | |
| 14 | + | return false | |
| 15 | + | } | |
| 16 | + | if rt.copySearchActive() { | |
| 17 | + | // Search owns ordinary mouse input, but a release completes an | |
| 18 | + | // in-flight drag. Route it through the active mapper so its endpoint is | |
| 19 | + | // clamped against the press-owned geometry before clearing the pointer. | |
| 20 | + | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |
| 21 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 22 | + | } | |
| 23 | + | return true | |
| 24 | + | } | |
| 25 | + | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |
| 26 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 27 | + | return true | |
| 28 | + | } | |
| 29 | + | switch ev.Button { | |
| 30 | + | case mouse.WheelUp: | |
| 31 | + | d.copyWheel(sess, ac, -3) | |
| 32 | + | case mouse.WheelDown: | |
| 33 | + | d.copyWheel(sess, ac, 3) | |
| 34 | + | case mouse.Left: | |
| 35 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 36 | + | } | |
| 37 | + | return true | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |
| 41 | + | // event that cannot be routed to terminal content. It never runs while tab or | |
| 42 | + | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |
| 43 | + | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |
| 44 | + | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | rt.copyMu.Lock() | |
| 48 | + | rt.invalidateCopyPointerLocked(true) | |
| 49 | + | rt.copyMu.Unlock() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // handleFreshCopyPress is the only normal-screen press path that creates a | |
| 53 | + | // selection candidate. Its geometry comes from the same frame-absolute hit | |
| 54 | + | // test used by active copy mode, never from pane-local synthetic rectangles. | |
| 55 | + | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |
| 56 | + | if routed == nil { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | tb.mu.Lock() | |
| 60 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 61 | + | tb.mu.Unlock() | |
| 62 | + | if !ok || geometry.pane != routed { | |
| 63 | + | ac.overlays.copyMu.Lock() | |
| 64 | + | ac.overlays.invalidateCopyPointerLocked(true) | |
| 65 | + | ac.overlays.copyMu.Unlock() | |
| 66 | + | return true | |
| 67 | + | } | |
| 68 | + | geometry.pane.mu.Lock() | |
| 69 | + | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 70 | + | altScreen := geometry.pane.screen.AltScreenActive() | |
| 71 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |
| 72 | + | geometry.pane.mu.Unlock() | |
| 73 | + | if mouseMode != 0 || altScreen { | |
| 74 | + | return false // child forwarding retains its existing raw-byte path. | |
| 75 | + | } | |
| 76 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |
| 77 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 78 | + | if !ok { | |
| 79 | + | ac.overlays.copyMu.Lock() | |
| 80 | + | ac.overlays.invalidateCopyPointerLocked(true) | |
| 81 | + | ac.overlays.copyMu.Unlock() | |
| 82 | + | return true | |
| 83 | + | } | |
| 84 | + | rt := ac.overlays | |
| 85 | + | rt.copyMu.Lock() | |
| 86 | + | if rt.copyMode != nil || rt.copyCandidate != nil { | |
| 87 | + | rt.copyMu.Unlock() | |
| 88 | + | return true | |
| 89 | + | } | |
| 90 | + | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |
| 91 | + | pointer := rt.copyPointer | |
| 92 | + | now := d.clock.Now() | |
| 93 | + | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |
| 94 | + | if doubleClick { | |
| 95 | + | rt.copyClick = copyClickCandidate{} | |
| 96 | + | } else { | |
| 97 | + | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |
| 98 | + | } | |
| 99 | + | rt.copyMu.Unlock() | |
| 100 | + | if !doubleClick { | |
| 101 | + | return true | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | selectedWord := false | |
| 105 | + | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |
| 106 | + | mode.SetPosition(mapped.pos) | |
| 107 | + | selectedWord = mode.SelectWordAt(mapped.pos) | |
| 108 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 109 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | pointer.wordDrag = selectedWord | |
| 113 | + | runtime.copyPointer = pointer | |
| 114 | + | }) { | |
| 115 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 116 | + | } | |
| 117 | + | return true | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |
| 121 | + | // and geometry. It is intentionally independent of the currently hovered pane. | |
| 122 | + | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |
| 123 | + | rt := ac.overlays | |
| 124 | + | rt.copyMu.Lock() | |
| 125 | + | pointer := rt.copyPointer | |
| 126 | + | epoch := rt.copyPointerEpoch | |
| 127 | + | active := rt.copyMode != nil || rt.copyCandidate != nil | |
| 128 | + | rt.copyMu.Unlock() | |
| 129 | + | if !pointer.valid { | |
| 130 | + | if active && ev.Type == mouse.Release { | |
| 131 | + | rt.copyMu.Lock() | |
| 132 | + | if rt.copyPointerEpoch == epoch { | |
| 133 | + | rt.invalidateCopyPointerLocked(false) | |
| 134 | + | } | |
| 135 | + | rt.copyMu.Unlock() | |
| 136 | + | return true | |
| 137 | + | } | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | if active { | |
| 141 | + | if ev.Type == mouse.Release { | |
| 142 | + | rt.copyMu.Lock() | |
| 143 | + | if rt.copyPointerEpoch == epoch { | |
| 144 | + | rt.invalidateCopyPointerLocked(false) | |
| 145 | + | } | |
| 146 | + | rt.copyMu.Unlock() | |
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | return false | |
| 150 | + | } | |
| 151 | + | pointer.pane.mu.Lock() | |
| 152 | + | mouseMode, _ := pointer.pane.screen.MouseMode() | |
| 153 | + | altScreen := pointer.pane.screen.AltScreenActive() | |
| 154 | + | pointer.pane.mu.Unlock() | |
| 155 | + | if mouseMode != 0 || altScreen { | |
| 156 | + | rt.copyMu.Lock() | |
| 157 | + | if rt.copyPointerEpoch == epoch { | |
| 158 | + | rt.invalidateCopyPointerLocked(true) | |
| 159 | + | } | |
| 160 | + | rt.copyMu.Unlock() | |
| 161 | + | return false // preserve child forwarding when it enabled mouse reporting. | |
| 162 | + | } | |
| 163 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |
| 164 | + | if ev.Type == mouse.Motion { | |
| 165 | + | rt.copyMu.Lock() | |
| 166 | + | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| 167 | + | rt.copyClick.dragged = true | |
| 168 | + | } | |
| 169 | + | rt.copyMu.Unlock() | |
| 170 | + | } | |
| 171 | + | if ev.Type == mouse.Release { | |
| 172 | + | rt.copyMu.Lock() | |
| 173 | + | if rt.copyPointerEpoch == epoch { | |
| 174 | + | rt.invalidateCopyPointerLocked(false) | |
| 175 | + | } | |
| 176 | + | rt.copyMu.Unlock() | |
| 177 | + | return true | |
| 178 | + | } | |
| 179 | + | if !ok { | |
| 180 | + | return true | |
| 181 | + | } | |
| 182 | + | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 183 | + | mode.StartCharacterSelection(pointer.press) | |
| 184 | + | mode.ExtendCharacterSelection(mapped.pos) | |
| 185 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 186 | + | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | pointer.dragging = true | |
| 190 | + | runtime.copyPointer = pointer | |
| 191 | + | }) { | |
| 192 | + | return true | |
| 193 | + | } | |
| 194 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 195 | + | return true | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | type copyMouseInputSnapshot struct { | |
| 199 | + | mode *scopy.Mode | |
| 200 | + | pane *pane | |
| 201 | + | document *scopy.Document | |
| 202 | + | viewportTop int | |
| 203 | + | pointer copyPointerState | |
| 204 | + | epoch uint64 | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |
| 208 | + | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |
| 209 | + | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |
| 210 | + | if rt == nil { | |
| 211 | + | return copyMouseInputSnapshot{}, false | |
| 212 | + | } | |
| 213 | + | rt.copyMu.Lock() | |
| 214 | + | defer rt.copyMu.Unlock() | |
| 215 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 216 | + | return copyMouseInputSnapshot{}, false | |
| 217 | + | } | |
| 218 | + | return copyMouseInputSnapshot{ | |
| 219 | + | mode: rt.copyMode, | |
| 220 | + | pane: rt.copyPane, | |
| 221 | + | document: rt.copyDocument, | |
| 222 | + | viewportTop: rt.copyMode.ViewportTop, | |
| 223 | + | pointer: rt.copyPointer, | |
| 224 | + | epoch: rt.copyPointerEpoch, | |
| 225 | + | }, true | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // invalidateSnapshotCopyPointer clears only the pointer captured with the | |
| 229 | + | // input snapshot. Mapping runs outside copyMu, so a newer press can otherwise | |
| 230 | + | // be erased by an older rejected event. | |
| 231 | + | func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) { | |
| 232 | + | rt.copyMu.Lock() | |
| 233 | + | defer rt.copyMu.Unlock() | |
| 234 | + | if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode || | |
| 235 | + | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | rt.invalidateCopyPointerLocked(true) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| 242 | + | // press time. Geometry is resolved after copyMu is released to preserve the | |
| 243 | + | // daemon's copyMu -> tab/pane lock prohibition. | |
| 244 | + | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |
| 245 | + | rt := ac.overlays | |
| 246 | + | snapshot, active := snapshotCopyMouseInput(rt) | |
| 247 | + | if !active { | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | if d.beforeCopyMouseMap != nil { | |
| 251 | + | d.beforeCopyMouseMap() | |
| 252 | + | } | |
| 253 | + | cfg := d.currentFloatingConfig() | |
| 254 | + | // Once pressed, use the committed rectangle captured by that press. A | |
| 255 | + | // later floating/layout change must not turn an in-flight drag into a hit | |
| 256 | + | // on a different pane. | |
| 257 | + | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |
| 258 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion || ev.Type == mouse.Release) | |
| 259 | + | if ev.Type == mouse.Release && !ok { | |
| 260 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | if ok { | |
| 264 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) | |
| 265 | + | } | |
| 266 | + | return | |
| 267 | + | } | |
| 268 | + | tb.mu.Lock() | |
| 269 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 270 | + | otherPane := false | |
| 271 | + | if ev.Type == mouse.Press { | |
| 272 | + | // A new press is allowed to change focus, but never the document owned | |
| 273 | + | // by the active copy mode. Do all tab work first; exitCopyMode takes | |
| 274 | + | // copyMu only after tb.mu is released. | |
| 275 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 276 | + | otherPane = true | |
| 277 | + | if tb.tree != nil { | |
| 278 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 279 | + | d.applyLayoutLocked(tb) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | tb.mu.Unlock() | |
| 284 | + | if otherPane { | |
| 285 | + | d.exitCopyMode(ac) | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | if !ok { | |
| 289 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 290 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 291 | + | } | |
| 292 | + | return | |
| 293 | + | } | |
| 294 | + | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |
| 295 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |
| 296 | + | if !mappedOK { | |
| 297 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 298 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 299 | + | } | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) | |
| 303 | + | } | |
A internal/usecase/daemon/copy_mouse_map.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // clientTopBarRows is deliberately kept at the client/frame boundary. Layout | |
| 10 | + | // coordinates are content-relative and must cross this boundary exactly once. | |
| 11 | + | const clientTopBarRows = 1 | |
| 12 | + | ||
| 13 | + | type copyMouseGeometry struct { | |
| 14 | + | pane *pane | |
| 15 | + | content domain.Rect // absolute client-frame coordinates | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type mappedCopyMouse struct { | |
| 19 | + | pane *pane | |
| 20 | + | pos scopy.Pos | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func copyContentToClientRect(content domain.Rect) domain.Rect { | |
| 24 | + | content.Y += clientTopBarRows | |
| 25 | + | return content | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func mapCopyMouse(ev mouse.Event, geometry copyMouseGeometry, viewportTop int, document *scopy.Document, clamp bool) (mappedCopyMouse, bool) { | |
| 29 | + | if document == nil || geometry.pane == nil || geometry.content.Width <= 0 || geometry.content.Height <= 0 { | |
| 30 | + | return mappedCopyMouse{}, false | |
| 31 | + | } | |
| 32 | + | col, row := ev.Col, ev.Row | |
| 33 | + | if clamp { | |
| 34 | + | col = clampInt(col, geometry.content.X, geometry.content.X+geometry.content.Width-1) | |
| 35 | + | row = clampInt(row, geometry.content.Y, geometry.content.Y+geometry.content.Height-1) | |
| 36 | + | } else if !pointInRect(col, row, geometry.content) { | |
| 37 | + | return mappedCopyMouse{}, false | |
| 38 | + | } | |
| 39 | + | pos, ok := document.Normalize(scopy.Pos{Row: viewportTop + row - geometry.content.Y, Col: col - geometry.content.X}) | |
| 40 | + | if !ok { | |
| 41 | + | return mappedCopyMouse{}, false | |
| 42 | + | } | |
| 43 | + | return mappedCopyMouse{pane: geometry.pane, pos: pos}, true | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // hitTestCopyMouseGeometryLocked resolves a new press. Floating geometry is | |
| 47 | + | // exclusive while visible; normal title bars and dividers are intentionally | |
| 48 | + | // not copy content. Caller holds tb.mu. | |
| 49 | + | func hitTestCopyMouseGeometryLocked(tb *tab, cfg domain.FloatingConfig, col, row int) (copyMouseGeometry, bool) { | |
| 50 | + | if tb == nil || row < clientTopBarRows { | |
| 51 | + | return copyMouseGeometry{}, false | |
| 52 | + | } | |
| 53 | + | if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible { | |
| 54 | + | content := copyContentToClientRect(geometry.Inner) | |
| 55 | + | if !pointInRect(col, row, content) { | |
| 56 | + | return copyMouseGeometry{}, false | |
| 57 | + | } | |
| 58 | + | return copyMouseGeometry{pane: p, content: content}, true | |
| 59 | + | } | |
| 60 | + | pl, ok := hitTestPlacementLocked(tb, col, row-clientTopBarRows) | |
| 61 | + | if !ok || pl.Collapsed || !pointInRect(col, row-clientTopBarRows, pl.Content) { | |
| 62 | + | return copyMouseGeometry{}, false | |
| 63 | + | } | |
| 64 | + | p := tb.panes[pl.ID] | |
| 65 | + | if p == nil { | |
| 66 | + | return copyMouseGeometry{}, false | |
| 67 | + | } | |
| 68 | + | return copyMouseGeometry{pane: p, content: copyContentToClientRect(pl.Content)}, true | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // copyMouseGeometryForPaneLocked resolves geometry for an existing drag. It | |
| 72 | + | // never hit-tests the new pointer, preventing cross-pane document switches. | |
| 73 | + | func copyMouseGeometryForPaneLocked(tb *tab, cfg domain.FloatingConfig, target *pane) (copyMouseGeometry, bool) { | |
| 74 | + | if tb == nil || target == nil { | |
| 75 | + | return copyMouseGeometry{}, false | |
| 76 | + | } | |
| 77 | + | if p, geometry, visible := tb.visibleFloatingSnapshotLocked(cfg); visible { | |
| 78 | + | if p != target { | |
| 79 | + | return copyMouseGeometry{}, false | |
| 80 | + | } | |
| 81 | + | return copyMouseGeometry{pane: p, content: copyContentToClientRect(geometry.Inner)}, true | |
| 82 | + | } | |
| 83 | + | if tb.panes[target.id] != target { | |
| 84 | + | return copyMouseGeometry{}, false | |
| 85 | + | } | |
| 86 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 87 | + | if !ok { | |
| 88 | + | return copyMouseGeometry{}, false | |
| 89 | + | } | |
| 90 | + | for _, pl := range placements { | |
| 91 | + | if pl.ID == target.id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 92 | + | return copyMouseGeometry{pane: target, content: copyContentToClientRect(pl.Content)}, true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return copyMouseGeometry{}, false | |
| 96 | + | } | |
A internal/usecase/daemon/copy_mouse_map_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCopyMouseMapTopBarAndContent(t *testing.T) { | |
| 15 | + | p := newPane("pane", nil, domain.Size{Cols: 5, Rows: 2}) | |
| 16 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("bravo")}, 5, 2), domain.DefaultWordSeparators) | |
| 17 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 0, Y: 1, Width: 5, Height: 2}} | |
| 18 | + | ||
| 19 | + | _, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 0}, geometry, 0, doc, false) | |
| 20 | + | require.False(t, ok) | |
| 21 | + | mapped, ok := mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, doc, false) | |
| 22 | + | require.True(t, ok) | |
| 23 | + | require.Same(t, p, mapped.pane) | |
| 24 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, mapped.pos) | |
| 25 | + | mapped, ok = mapCopyMouse(mouse.Event{Col: 4, Row: 2}, geometry, 0, doc, false) | |
| 26 | + | require.True(t, ok) | |
| 27 | + | require.Equal(t, scopy.Pos{Row: 1, Col: 4}, mapped.pos) | |
| 28 | + | _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 3}, geometry, 0, doc, false) | |
| 29 | + | require.False(t, ok, "bottom status is not content") | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestCopyMouseMapClampsAndNormalizesWideCells(t *testing.T) { | |
| 33 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 34 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: 'b'}}}, 4, 1), domain.DefaultWordSeparators) | |
| 35 | + | geometry := copyMouseGeometry{pane: p, content: domain.Rect{X: 3, Y: 1, Width: 4, Height: 1}} | |
| 36 | + | ||
| 37 | + | mapped, ok := mapCopyMouse(mouse.Event{Col: 5, Row: 1}, geometry, 0, doc, false) | |
| 38 | + | require.True(t, ok) | |
| 39 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 1}, mapped.pos) | |
| 40 | + | mapped, ok = mapCopyMouse(mouse.Event{Col: 99, Row: 99}, geometry, 0, doc, true) | |
| 41 | + | require.True(t, ok) | |
| 42 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 3}, mapped.pos) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestCopyMouseMapSplitAndFloatingRects(t *testing.T) { | |
| 46 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 2}) | |
| 47 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("abcd"), testRow("efgh")}, 4, 2), domain.DefaultWordSeparators) | |
| 48 | + | for _, tc := range []struct { | |
| 49 | + | name string | |
| 50 | + | geometry domain.Rect | |
| 51 | + | ev mouse.Event | |
| 52 | + | want scopy.Pos | |
| 53 | + | ok bool | |
| 54 | + | }{ | |
| 55 | + | {"split content", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 23, Row: 3}, scopy.Pos{Row: 1, Col: 2}, true}, | |
| 56 | + | {"split title", domain.Rect{X: 21, Y: 2, Width: 4, Height: 2}, mouse.Event{Col: 21, Row: 1}, scopy.Pos{}, false}, | |
| 57 | + | {"floating inner", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 11, Row: 5}, scopy.Pos{Row: 0, Col: 1}, true}, | |
| 58 | + | {"floating border", domain.Rect{X: 10, Y: 5, Width: 4, Height: 2}, mouse.Event{Col: 9, Row: 5}, scopy.Pos{}, false}, | |
| 59 | + | } { | |
| 60 | + | t.Run(tc.name, func(t *testing.T) { | |
| 61 | + | mapped, ok := mapCopyMouse(tc.ev, copyMouseGeometry{pane: p, content: tc.geometry}, 0, doc, false) | |
| 62 | + | require.Equal(t, tc.ok, ok) | |
| 63 | + | if ok { | |
| 64 | + | require.Equal(t, tc.want, mapped.pos) | |
| 65 | + | } | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCopyMouseGeometryUsesClientFrameExactlyOnce(t *testing.T) { | |
| 71 | + | p, release := newBlockingPTY(t) | |
| 72 | + | defer release() | |
| 73 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 74 | + | tb := sess.activeTab() | |
| 75 | + | ||
| 76 | + | tb.mu.Lock() | |
| 77 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 0, 1) | |
| 78 | + | tb.mu.Unlock() | |
| 79 | + | require.True(t, ok) | |
| 80 | + | require.Equal(t, 1, geometry.content.Y) | |
| 81 | + | _, ok = mapCopyMouse(mouse.Event{Col: 0, Row: 1}, geometry, 0, | |
| 82 | + | scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("a")}, 80, 1), domain.DefaultWordSeparators), false) | |
| 83 | + | require.True(t, ok) | |
| 84 | + | } | |
M internal/usecase/daemon/copymode.go +149 -142
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "strconv" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| @@ -16,6 +17,17 @@ import ( | |||
| 16 | 17 | ||
| 17 | 18 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | |
| 18 | 19 | ||
| 20 | + | const copyDoubleClickInterval = 500 * time.Millisecond | |
| 21 | + | ||
| 22 | + | func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col } | |
| 23 | + | ||
| 24 | + | func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool { | |
| 25 | + | candidate := rt.copyClick | |
| 26 | + | return candidate.valid && !candidate.dragged && candidate.pane == pane && | |
| 27 | + | sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) && | |
| 28 | + | now.Sub(candidate.at) <= copyDoubleClickInterval | |
| 29 | + | } | |
| 30 | + | ||
| 19 | 31 | func copyTargetPane(rt *overlayRuntime) *pane { | |
| 20 | 32 | if rt == nil { | |
| 21 | 33 | return nil | |
| @@ -31,27 +43,24 @@ func copyTargetPane(rt *overlayRuntime) *pane { | |||
| 31 | 43 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 32 | 44 | rt := ac.overlays | |
| 33 | 45 | rt.copyMu.Lock() | |
| 34 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil { | |
| 46 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 35 | 47 | rt.copyMu.Unlock() | |
| 36 | 48 | return | |
| 37 | 49 | } | |
| 38 | - | document := rt.copySnapshot | |
| 39 | - | if delta > 0 && rt.copyMode.AtBottom(*document) { | |
| 50 | + | if delta > 0 && rt.copyMode.AtBottom() { | |
| 40 | 51 | rt.clearCopyModeLocked() | |
| 41 | 52 | rt.copyMu.Unlock() | |
| 42 | 53 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 43 | 54 | return | |
| 44 | 55 | } | |
| 45 | - | rt.copyMode.Move(*document, delta) | |
| 46 | - | exit := delta > 0 && rt.copyMode.AtBottom(*document) | |
| 56 | + | rt.copyMode.MoveRows(delta) | |
| 57 | + | exit := delta > 0 && rt.copyMode.AtBottom() | |
| 47 | 58 | if exit { | |
| 48 | 59 | rt.clearCopyModeLocked() | |
| 49 | 60 | } | |
| 50 | 61 | rt.copyMu.Unlock() | |
| 51 | - | ||
| 52 | 62 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 53 | 63 | } | |
| 54 | - | ||
| 55 | 64 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 56 | 65 | tb := sess.activeTab() | |
| 57 | 66 | if tb == nil { | |
| @@ -63,23 +72,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 63 | 72 | if p == nil { | |
| 64 | 73 | return | |
| 65 | 74 | } | |
| 66 | - | // Capture the live pane under pane.mu; all subsequent copy interaction uses | |
| 67 | - | // the immutable document while holding only copyMu. | |
| 68 | 75 | p.mu.Lock() | |
| 69 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 76 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 70 | 77 | p.mu.Unlock() | |
| 71 | - | if !d.publishCopyMode(sess, ac, tb, p, document, nil) { | |
| 78 | + | cfg := d.currentCopyConfig() | |
| 79 | + | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| 80 | + | if !d.publishCopyMode(sess, ac, tb, p, document, nil, nil) { | |
| 72 | 81 | return | |
| 73 | 82 | } | |
| 74 | 83 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 75 | 84 | } | |
| 76 | 85 | ||
| 77 | - | // publishCopyMode installs a non-renderable candidate, validates its captured | |
| 78 | - | // target without overlapping copyMu with session or tab locks, then atomically | |
| 79 | - | // activates that same candidate. A tab transition, pane close, or newer | |
| 80 | - | // publication invalidates the candidate before it can render or be yanked. | |
| 81 | - | func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool { | |
| 82 | - | if sess == nil || ac == nil || tb == nil || p == nil { | |
| 86 | + | // publishCopyMode serializes overlay publication after pane membership is | |
| 87 | + | // revalidated. Document is the sole immutable copy payload. | |
| 88 | + | func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document *scopy.Document, prepare func(*scopy.Mode), activate func(*overlayRuntime, *scopy.Mode)) bool { | |
| 89 | + | if sess == nil || ac == nil || tb == nil || p == nil || document == nil { | |
| 83 | 90 | return false | |
| 84 | 91 | } | |
| 85 | 92 | mode := scopy.NewMode(document) | |
| @@ -92,26 +99,33 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 92 | 99 | rt.copyPending = nil | |
| 93 | 100 | rt.copyMode = nil | |
| 94 | 101 | rt.copyCandidate = mode | |
| 95 | - | rt.copySnapshot = &document | |
| 102 | + | rt.copyDocument = document | |
| 96 | 103 | rt.copyPane = p | |
| 97 | 104 | rt.copySearch = nil | |
| 98 | 105 | rt.copySearchPending = nil | |
| 99 | - | rt.copyPressRowValid = false | |
| 100 | - | rt.copyDragging = false | |
| 101 | - | rt.normalMousePressValid = false | |
| 106 | + | if activate != nil { | |
| 107 | + | rt.clearCopyPointerForTransferLocked() | |
| 108 | + | } else { | |
| 109 | + | rt.invalidateCopyPointerLocked(true) | |
| 110 | + | } | |
| 111 | + | pointerEpoch := rt.copyPointerEpoch | |
| 102 | 112 | rt.copyMu.Unlock() | |
| 103 | - | ||
| 104 | - | active := sess.activeTab() | |
| 105 | - | valid := active == tb | |
| 113 | + | if d.beforeCopyModeRevalidate != nil { | |
| 114 | + | d.beforeCopyModeRevalidate() | |
| 115 | + | } | |
| 116 | + | valid := sess.activeTab() == tb | |
| 106 | 117 | if valid { | |
| 107 | 118 | tb.mu.Lock() | |
| 108 | 119 | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | |
| 109 | 120 | tb.mu.Unlock() | |
| 110 | 121 | } | |
| 111 | - | ||
| 112 | 122 | rt.copyMu.Lock() | |
| 113 | 123 | defer rt.copyMu.Unlock() | |
| 114 | - | if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document { | |
| 124 | + | if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document { | |
| 125 | + | return false | |
| 126 | + | } | |
| 127 | + | if pointerEpoch != rt.copyPointerEpoch { | |
| 128 | + | rt.discardCopyCandidateLocked(mode) | |
| 115 | 129 | return false | |
| 116 | 130 | } | |
| 117 | 131 | if !valid { | |
| @@ -120,52 +134,76 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 120 | 134 | } | |
| 121 | 135 | rt.copyCandidate = nil | |
| 122 | 136 | rt.copyMode = mode | |
| 137 | + | if activate != nil { | |
| 138 | + | activate(rt, mode) | |
| 139 | + | } | |
| 123 | 140 | return true | |
| 124 | 141 | } | |
| 125 | 142 | ||
| 126 | - | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 143 | + | // copyMouse receives an already mapped client-frame position. It revalidates | |
| 144 | + | // the immutable snapshot under copyMu before changing selection state, so a | |
| 145 | + | // concurrent scroll or pointer lifecycle transition cannot apply stale rows. | |
| 146 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse, snapshot copyMouseInputSnapshot, geometry copyMouseGeometry) { | |
| 127 | 147 | rt := ac.overlays | |
| 128 | 148 | if ev.Button != mouse.Left { | |
| 129 | 149 | return | |
| 130 | 150 | } | |
| 131 | 151 | rt.copyMu.Lock() | |
| 132 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil { | |
| 152 | + | if rt.copyMode != snapshot.mode || rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document || | |
| 153 | + | rt.copyPointerEpoch != snapshot.epoch || rt.copyMode == nil || rt.copyDocument != rt.copyMode.Document() || | |
| 154 | + | rt.copyMode.ViewportTop != snapshot.viewportTop { | |
| 133 | 155 | rt.copyMu.Unlock() | |
| 134 | 156 | return | |
| 135 | 157 | } | |
| 136 | - | document := rt.copySnapshot | |
| 137 | - | if ev.Row >= document.Height { | |
| 138 | - | if ev.Type == mouse.Press { | |
| 139 | - | rt.copyPressRowValid = false | |
| 140 | - | rt.copyDragging = false | |
| 141 | - | } | |
| 142 | - | rt.copyMu.Unlock() | |
| 143 | - | return | |
| 144 | - | } | |
| 145 | - | absRow := rt.copyMode.ViewportTop + ev.Row | |
| 146 | 158 | changed := false | |
| 147 | 159 | switch ev.Type { | |
| 148 | 160 | case mouse.Press: | |
| 149 | - | rt.copyMode.SetCursor(*document, absRow) | |
| 150 | - | rt.copyPressRow = rt.copyMode.Cursor | |
| 151 | - | rt.copyPressRowValid = true | |
| 152 | - | rt.copyDragging = false | |
| 153 | - | changed = true | |
| 154 | - | case mouse.Motion: | |
| 155 | - | if !rt.copyPressRowValid { | |
| 156 | - | break | |
| 161 | + | changed = rt.copyMode.SetPosition(mapped.pos) | |
| 162 | + | pointer := rt.copyPointer | |
| 163 | + | if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument { | |
| 164 | + | rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos}) | |
| 165 | + | pointer = rt.copyPointer | |
| 157 | 166 | } | |
| 158 | - | if !rt.copyDragging { | |
| 159 | - | rt.copyMode.StartSelectionAt(*document, rt.copyPressRow) | |
| 160 | - | rt.copyDragging = true | |
| 167 | + | now := d.clock.Now() | |
| 168 | + | if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) { | |
| 169 | + | pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos) | |
| 170 | + | changed = pointer.wordDrag || changed | |
| 171 | + | rt.copyClick = copyClickCandidate{} | |
| 172 | + | } else { | |
| 173 | + | rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now} | |
| 174 | + | } | |
| 175 | + | rt.copyPointer = pointer | |
| 176 | + | case mouse.Motion: | |
| 177 | + | pointer := rt.copyPointer | |
| 178 | + | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument { | |
| 179 | + | if !pointer.dragging { | |
| 180 | + | if !pointer.wordDrag { | |
| 181 | + | rt.copyMode.StartCharacterSelection(pointer.press) | |
| 182 | + | } | |
| 183 | + | pointer.dragging = true | |
| 184 | + | } | |
| 185 | + | if rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| 186 | + | rt.copyClick.dragged = true | |
| 187 | + | } | |
| 188 | + | if pointer.wordDrag { | |
| 189 | + | changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed | |
| 190 | + | } else { | |
| 191 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |
| 192 | + | } | |
| 193 | + | rt.copyPointer = pointer | |
| 161 | 194 | } | |
| 162 | - | rt.copyMode.ExtendTo(*document, absRow) | |
| 163 | - | changed = true | |
| 164 | 195 | case mouse.Release: | |
| 165 | - | // Button release intentionally has no visual effect. | |
| 196 | + | pointer := rt.copyPointer | |
| 197 | + | if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging { | |
| 198 | + | if pointer.wordDrag { | |
| 199 | + | changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed | |
| 200 | + | } else { | |
| 201 | + | changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | rt.invalidateCopyPointerLocked(false) | |
| 166 | 205 | } | |
| 167 | 206 | rt.copyMu.Unlock() | |
| 168 | - | ||
| 169 | 207 | if changed { | |
| 170 | 208 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 171 | 209 | } | |
| @@ -178,7 +216,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 178 | 216 | return | |
| 179 | 217 | } | |
| 180 | 218 | rt.copyMu.Lock() | |
| 181 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil { | |
| 219 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 182 | 220 | rt.copyPending = nil | |
| 183 | 221 | d.stopCopyPendingTimerLocked(ac) | |
| 184 | 222 | rt.copyMu.Unlock() | |
| @@ -186,61 +224,71 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 186 | 224 | } | |
| 187 | 225 | if len(rt.copyPending) > 0 { | |
| 188 | 226 | d.stopCopyPendingTimerLocked(ac) | |
| 189 | - | combined := make([]byte, 0, len(rt.copyPending)+len(data)) | |
| 190 | - | combined = append(combined, rt.copyPending...) | |
| 191 | - | combined = append(combined, data...) | |
| 192 | - | data = combined | |
| 227 | + | data = append(append([]byte(nil), rt.copyPending...), data...) | |
| 193 | 228 | rt.copyPending = nil | |
| 194 | 229 | } | |
| 195 | - | document := rt.copySnapshot | |
| 196 | 230 | if rt.copySearch != nil { | |
| 197 | - | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data) | |
| 231 | + | changed, close, accepted := d.routeCopySearchInputLocked(rt, data) | |
| 198 | 232 | rt.copyMu.Unlock() | |
| 199 | - | if changed || closeSearch || accepted { | |
| 233 | + | if changed || close || accepted { | |
| 200 | 234 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 201 | 235 | } | |
| 202 | 236 | return | |
| 203 | 237 | } | |
| 204 | - | changed := false | |
| 205 | - | copyOut := false | |
| 206 | - | exit := false | |
| 238 | + | changed, copyOut, exit := false, false, false | |
| 207 | 239 | for i := 0; i < len(data); i++ { | |
| 208 | 240 | switch data[i] { | |
| 241 | + | case 'h': | |
| 242 | + | rt.copyMode.Left() | |
| 243 | + | changed = true | |
| 209 | 244 | case 'j': | |
| 210 | - | rt.copyMode.Move(*document, 1) | |
| 245 | + | rt.copyMode.Down() | |
| 211 | 246 | changed = true | |
| 212 | 247 | case 'k': | |
| 213 | - | rt.copyMode.Move(*document, -1) | |
| 248 | + | rt.copyMode.Up() | |
| 249 | + | changed = true | |
| 250 | + | case 'l': | |
| 251 | + | rt.copyMode.Right() | |
| 252 | + | changed = true | |
| 253 | + | case 'w': | |
| 254 | + | rt.copyMode.WordNext() | |
| 255 | + | changed = true | |
| 256 | + | case 'b': | |
| 257 | + | rt.copyMode.WordBackward() | |
| 258 | + | changed = true | |
| 259 | + | case 'e': | |
| 260 | + | rt.copyMode.WordEnd() | |
| 214 | 261 | changed = true | |
| 215 | 262 | case 'g': | |
| 216 | - | rt.copyMode.Top(*document) | |
| 263 | + | rt.copyMode.Top() | |
| 217 | 264 | changed = true | |
| 218 | 265 | case 'G': | |
| 219 | - | rt.copyMode.Bottom(*document) | |
| 266 | + | rt.copyMode.Bottom() | |
| 220 | 267 | changed = true | |
| 221 | 268 | case ' ', 'v': | |
| 222 | - | rt.copyMode.ToggleSelection() | |
| 269 | + | rt.copyMode.ToggleLineSelection() | |
| 223 | 270 | changed = true | |
| 224 | 271 | case '/': | |
| 225 | - | rt.copySearch = visualsearch.New(*document) | |
| 272 | + | rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot()) | |
| 226 | 273 | rt.copySearchPending = nil | |
| 227 | 274 | changed = true | |
| 228 | 275 | if i+1 < len(data) { | |
| 229 | - | searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:]) | |
| 230 | - | changed = changed || searchChanged || accepted | |
| 276 | + | a, b, c := d.routeCopySearchInputLocked(rt, data[i+1:]) | |
| 277 | + | changed = changed || a || c | |
| 278 | + | _ = b | |
| 231 | 279 | } | |
| 232 | 280 | i = len(data) | |
| 233 | 281 | case 'n': | |
| 234 | - | changed = rt.copyMode.NextSearchMatch(*document, 1) || changed | |
| 282 | + | changed = rt.copyMode.NextSearchMatch(1) || changed | |
| 235 | 283 | case 'N': | |
| 236 | - | changed = rt.copyMode.NextSearchMatch(*document, -1) || changed | |
| 284 | + | changed = rt.copyMode.NextSearchMatch(-1) || changed | |
| 237 | 285 | case '\r', '\n', 'y': | |
| 238 | 286 | copyOut = true | |
| 239 | 287 | exit = true | |
| 240 | 288 | case 'q', 0x03, 0x1b: | |
| 241 | 289 | if data[i] == 0x1b { | |
| 242 | 290 | tail := data[i:] | |
| 243 | - | consumed, ok := routeCopyEscape(rt.copyMode, *document, tail) | |
| 291 | + | consumed, ok := routeCopyEscape(rt.copyMode, tail) | |
| 244 | 292 | if ok { | |
| 245 | 293 | i += consumed - 1 | |
| 246 | 294 | changed = true | |
| @@ -260,14 +308,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 260 | 308 | } | |
| 261 | 309 | text := "" | |
| 262 | 310 | if copyOut { | |
| 263 | - | text = rt.copyMode.SelectedText(*document) | |
| 311 | + | text = rt.copyMode.SelectedText() | |
| 264 | 312 | } | |
| 265 | 313 | if exit { | |
| 266 | 314 | d.stopCopyPendingTimerLocked(ac) | |
| 267 | 315 | rt.clearCopyModeLocked() | |
| 268 | 316 | } | |
| 269 | 317 | rt.copyMu.Unlock() | |
| 270 | - | ||
| 271 | 318 | if copyOut && text != "" { | |
| 272 | 319 | chunks := scopy.OSC52(text) | |
| 273 | 320 | for _, chunk := range chunks { | |
| @@ -285,63 +332,34 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 285 | 332 | } | |
| 286 | 333 | rt.copyMu.Unlock() | |
| 287 | 334 | } | |
| 288 | - | if exit { | |
| 289 | - | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 290 | - | return | |
| 291 | - | } | |
| 292 | - | if changed { | |
| 335 | + | if exit || changed { | |
| 293 | 336 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 294 | 337 | } | |
| 295 | 338 | } | |
| 296 | - | ||
| 297 | - | func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, snap scopy.Snapshot, data []byte) (changed bool, closeSearch bool, accepted bool) { | |
| 298 | - | routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{ | |
| 299 | - | rune: func(r rune) { | |
| 300 | - | rt.copySearch.Insert(r) | |
| 301 | - | changed = true | |
| 302 | - | }, | |
| 303 | - | backspace: func() { | |
| 304 | - | rt.copySearch.Backspace() | |
| 305 | - | changed = true | |
| 306 | - | }, | |
| 307 | - | enter: func() { | |
| 308 | - | if _, ok := rt.copySearch.Selected(); ok { | |
| 309 | - | searchSnap := rt.copySearch.Snapshot() | |
| 310 | - | accepted = rt.copyMode.SetSearchMatches(searchSnap, rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex()) | |
| 311 | - | closeSearch = accepted | |
| 312 | - | } | |
| 313 | - | }, | |
| 314 | - | cancel: func() { closeSearch = true }, | |
| 315 | - | up: func() { | |
| 316 | - | rt.copySearch.Up() | |
| 317 | - | changed = true | |
| 318 | - | }, | |
| 319 | - | down: func() { | |
| 320 | - | rt.copySearch.Down() | |
| 321 | - | changed = true | |
| 322 | - | }, | |
| 323 | - | }) | |
| 339 | + | func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) { | |
| 340 | + | routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() { | |
| 341 | + | if _, ok := rt.copySearch.Selected(); ok { | |
| 342 | + | accepted = rt.copyMode.SetSearchMatches(rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex()) | |
| 343 | + | closeSearch = accepted | |
| 344 | + | } | |
| 345 | + | }, cancel: func() { closeSearch = true }, up: func() { rt.copySearch.Up(); changed = true }, down: func() { rt.copySearch.Down(); changed = true }}) | |
| 324 | 346 | if changed && !closeSearch { | |
| 325 | - | d.previewCopySearchSelectionLocked(rt, snap) | |
| 347 | + | d.previewCopySearchSelectionLocked(rt) | |
| 326 | 348 | } | |
| 327 | 349 | if closeSearch { | |
| 328 | 350 | rt.copySearch = nil | |
| 329 | 351 | rt.copySearchPending = nil | |
| 330 | 352 | } | |
| 331 | - | return changed, closeSearch, accepted | |
| 353 | + | return | |
| 332 | 354 | } | |
| 333 | - | ||
| 334 | - | func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) { | |
| 355 | + | func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) { | |
| 335 | 356 | if rt == nil || rt.copyMode == nil || rt.copySearch == nil { | |
| 336 | 357 | return | |
| 337 | 358 | } | |
| 338 | - | match, ok := rt.copySearch.Selected() | |
| 339 | - | if !ok { | |
| 340 | - | return | |
| 359 | + | if match, ok := rt.copySearch.Selected(); ok { | |
| 360 | + | rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start}) | |
| 341 | 361 | } | |
| 342 | - | rt.copyMode.SetCursor(snap, match.Row) | |
| 343 | 362 | } | |
| 344 | - | ||
| 345 | 363 | func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |
| 346 | 364 | rt := ac.overlays | |
| 347 | 365 | mode := rt.copyMode | |
| @@ -361,53 +379,44 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||
| 361 | 379 | } | |
| 362 | 380 | rt.clearCopyModeLocked() | |
| 363 | 381 | rt.copyMu.Unlock() | |
| 364 | - | ||
| 365 | 382 | if sess := ac.currentSession(); sess != nil { | |
| 366 | 383 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 367 | 384 | } | |
| 368 | 385 | }) | |
| 369 | 386 | } | |
| 370 | - | ||
| 371 | - | func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { | |
| 372 | - | rt := ac.overlays | |
| 373 | - | rt.copyESC.stop() | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | |
| 387 | + | func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() } | |
| 388 | + | func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) { | |
| 377 | 389 | if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') { | |
| 378 | 390 | switch data[2] { | |
| 379 | 391 | case 'A': | |
| 380 | - | m.Move(snap, -1) | |
| 392 | + | m.Up() | |
| 381 | 393 | return 3, true | |
| 382 | 394 | case 'B': | |
| 383 | - | m.Move(snap, 1) | |
| 395 | + | m.Down() | |
| 384 | 396 | return 3, true | |
| 385 | 397 | } | |
| 386 | 398 | } | |
| 387 | 399 | if len(data) >= 4 && data[1] == '[' && data[3] == '~' { | |
| 388 | 400 | switch data[2] { | |
| 389 | 401 | case '5': | |
| 390 | - | m.Page(snap, -1) | |
| 402 | + | m.Page(-1) | |
| 391 | 403 | return 4, true | |
| 392 | 404 | case '6': | |
| 393 | - | m.Page(snap, 1) | |
| 405 | + | m.Page(1) | |
| 394 | 406 | return 4, true | |
| 395 | 407 | } | |
| 396 | 408 | } | |
| 397 | 409 | return 0, false | |
| 398 | 410 | } | |
| 399 | - | ||
| 400 | 411 | func isCopyEscapePrefix(data []byte) bool { | |
| 401 | - | return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || | |
| 402 | - | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 412 | + | return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 403 | 413 | } | |
| 404 | - | ||
| 405 | - | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 406 | - | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 414 | + | func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 415 | + | if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 407 | 416 | return frame, nil | |
| 408 | 417 | } | |
| 409 | 418 | styles := newThemeStyles(bars.theme) | |
| 410 | - | copyFrame := mode.Render(*document, styles.copyStatus, styles.selection) | |
| 419 | + | copyFrame := mode.Render(styles.copyStatus, styles.selection) | |
| 411 | 420 | bodyRows := max(copyFrame.Height-1, 0) | |
| 412 | 421 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| 413 | 422 | dstX := max(target.X, 0) | |
| @@ -419,10 +428,8 @@ func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target d | |||
| 419 | 428 | } | |
| 420 | 429 | } | |
| 421 | 430 | statusY := frame.Height - 1 | |
| 422 | - | statusRow := frame.Row(statusY) | |
| 423 | - | blank := renderer.BlankCell() | |
| 424 | - | for x := range statusRow { | |
| 425 | - | statusRow[x] = blank | |
| 431 | + | for x := range frame.Row(statusY) { | |
| 432 | + | frame.Row(statusY)[x] = renderer.BlankCell() | |
| 426 | 433 | } | |
| 427 | 434 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |
| 428 | 435 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
M internal/usecase/daemon/copymode_lifecycle_test.go +203 -23
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 7 | - | "github.com/stretchr/testify/assert" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| @@ -19,10 +19,17 @@ func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) { | |||
| 19 | 19 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 20 | 20 | d.enterCopyMode(sess, ac) | |
| 21 | 21 | require.True(t, ac.overlays.copyActive()) | |
| 22 | + | ac.overlays.copyMu.Lock() | |
| 23 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 24 | + | ac.overlays.copyMu.Unlock() | |
| 22 | 25 | ||
| 23 | 26 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |
| 24 | 27 | ||
| 25 | 28 | require.False(t, ac.overlays.copyActive()) | |
| 29 | + | ac.overlays.copyMu.Lock() | |
| 30 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 31 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 32 | + | ac.overlays.copyMu.Unlock() | |
| 26 | 33 | } | |
| 27 | 34 | ||
| 28 | 35 | func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |
| @@ -34,11 +41,18 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) { | |||
| 34 | 41 | }() | |
| 35 | 42 | d.enterCopyMode(sess, ac) | |
| 36 | 43 | require.True(t, ac.overlays.copyActive()) | |
| 44 | + | ac.overlays.copyMu.Lock() | |
| 45 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 46 | + | ac.overlays.copyMu.Unlock() | |
| 37 | 47 | ||
| 38 | 48 | require.True(t, sess.switchTab(1)) | |
| 39 | 49 | d.activateTab(sess, sess.activeTab()) | |
| 40 | 50 | ||
| 41 | 51 | require.False(t, ac.overlays.copyActive()) | |
| 52 | + | ac.overlays.copyMu.Lock() | |
| 53 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 54 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 55 | + | ac.overlays.copyMu.Unlock() | |
| 42 | 56 | } | |
| 43 | 57 | ||
| 44 | 58 | func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |
| @@ -54,17 +68,22 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||
| 54 | 68 | sess.client = ac | |
| 55 | 69 | ac.setSession(sess) | |
| 56 | 70 | closing.mu.Lock() | |
| 57 | - | document := scopy.NewSnapshot(closing.history, closing.screen.Frame) | |
| 71 | + | document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators) | |
| 58 | 72 | closing.mu.Unlock() | |
| 59 | 73 | ac.overlays.copyMu.Lock() | |
| 60 | 74 | ac.overlays.copyPane = closing | |
| 61 | - | ac.overlays.copySnapshot = &document | |
| 75 | + | ac.overlays.copyDocument = document | |
| 62 | 76 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 77 | + | seedCopyInteractionLocked(ac.overlays, closing, document) | |
| 63 | 78 | ac.overlays.copyMu.Unlock() | |
| 64 | 79 | ||
| 65 | 80 | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false)) | |
| 66 | 81 | require.False(t, ac.overlays.copyActive()) | |
| 67 | 82 | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 83 | + | ac.overlays.copyMu.Lock() | |
| 84 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 85 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 86 | + | ac.overlays.copyMu.Unlock() | |
| 68 | 87 | } | |
| 69 | 88 | ||
| 70 | 89 | func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) { | |
| @@ -94,39 +113,200 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 94 | 113 | tb := sess.activeTab() | |
| 95 | 114 | p := tb.focusedPane() | |
| 96 | 115 | p.mu.Lock() | |
| 97 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 116 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 98 | 117 | p.mu.Unlock() | |
| 99 | 118 | ||
| 100 | - | // Hold session validation after publication and make the captured target | |
| 101 | - | // stale. A render snapshot taken in this window must not expose the | |
| 102 | - | // candidate document. | |
| 103 | - | sess.mu.Lock() | |
| 104 | - | sess.active = 1 | |
| 119 | + | // Pause after candidate publication, then make the captured target stale. | |
| 120 | + | // A render snapshot while paused must not expose the candidate document. | |
| 121 | + | reached := make(chan struct{}) | |
| 122 | + | resume := make(chan struct{}) | |
| 123 | + | done := make(chan struct{}) | |
| 124 | + | d.beforeCopyModeRevalidate = func() { | |
| 125 | + | close(reached) | |
| 126 | + | <-resume | |
| 127 | + | } | |
| 128 | + | resumed := false | |
| 129 | + | defer func() { | |
| 130 | + | if !resumed { | |
| 131 | + | close(resume) | |
| 132 | + | } | |
| 133 | + | select { | |
| 134 | + | case <-done: | |
| 135 | + | d.beforeCopyModeRevalidate = nil | |
| 136 | + | case <-time.After(time.Second): | |
| 137 | + | t.Error("copy mode publication did not finish after revalidation gate was released") | |
| 138 | + | } | |
| 139 | + | }() | |
| 140 | + | ||
| 105 | 141 | result := make(chan bool, 1) | |
| 106 | 142 | go func() { | |
| 107 | - | result <- d.publishCopyMode(sess, ac, tb, p, document, nil) | |
| 143 | + | defer close(done) | |
| 144 | + | result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil) | |
| 108 | 145 | }() | |
| 109 | - | published := assert.Eventually(t, func() bool { | |
| 110 | - | ac.overlays.copyMu.Lock() | |
| 111 | - | defer ac.overlays.copyMu.Unlock() | |
| 112 | - | return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil | |
| 113 | - | }, time.Second, time.Millisecond) | |
| 114 | - | if !published { | |
| 115 | - | sess.mu.Unlock() | |
| 116 | - | <-result | |
| 117 | - | return | |
| 146 | + | select { | |
| 147 | + | case <-reached: | |
| 148 | + | case <-time.After(time.Second): | |
| 149 | + | t.Fatal("copy mode publication did not reach revalidation gate") | |
| 118 | 150 | } | |
| 151 | + | ||
| 119 | 152 | renderSnapshot := ac.overlays.SnapshotForRender() | |
| 120 | 153 | candidateWasRenderable := renderSnapshot.copyActive | |
| 121 | 154 | renderSnapshot.Unlock() | |
| 122 | - | sess.mu.Unlock() | |
| 155 | + | require.True(t, sess.switchTab(1)) | |
| 156 | + | close(resume) | |
| 157 | + | resumed = true | |
| 123 | 158 | ||
| 124 | - | require.False(t, <-result) | |
| 159 | + | select { | |
| 160 | + | case published := <-result: | |
| 161 | + | require.False(t, published) | |
| 162 | + | case <-time.After(time.Second): | |
| 163 | + | t.Fatal("copy mode publication did not finish after revalidation gate was released") | |
| 164 | + | } | |
| 125 | 165 | require.False(t, candidateWasRenderable) | |
| 126 | 166 | require.False(t, ac.overlays.copyActive()) | |
| 127 | 167 | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 128 | 168 | } | |
| 129 | 169 | ||
| 170 | + | func TestCopyModeLifecyclePublicationEpochRejectsReleaseAndPaneClose(t *testing.T) { | |
| 171 | + | for _, tc := range []struct { | |
| 172 | + | name string | |
| 173 | + | invalidate func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) | |
| 174 | + | }{ | |
| 175 | + | { | |
| 176 | + | name: "release during publication", | |
| 177 | + | invalidate: func(_ *testing.T, d *Daemon, sess *session, ac *attachedClient, _ *tab, _ *pane) { | |
| 178 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |
| 179 | + | }, | |
| 180 | + | }, | |
| 181 | + | { | |
| 182 | + | name: "pane close during publication", | |
| 183 | + | invalidate: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, tb *tab, p *pane) { | |
| 184 | + | require.NoError(t, d.closePane(sess, tb, p.id, ac, false)) | |
| 185 | + | }, | |
| 186 | + | }, | |
| 187 | + | } { | |
| 188 | + | t.Run(tc.name, func(t *testing.T) { | |
| 189 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 190 | + | defer func() { | |
| 191 | + | for _, release := range releases { | |
| 192 | + | release() | |
| 193 | + | } | |
| 194 | + | }() | |
| 195 | + | tb := sess.activeTab() | |
| 196 | + | p := tb.focusedPane() | |
| 197 | + | ||
| 198 | + | // A real press is required: the motion publication must inherit this | |
| 199 | + | // pointer epoch, rather than publishing a fresh interaction. | |
| 200 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 201 | + | ac.overlays.copyMu.Lock() | |
| 202 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 203 | + | ac.overlays.copyMu.Unlock() | |
| 204 | + | ||
| 205 | + | reached := make(chan struct{}) | |
| 206 | + | resume := make(chan struct{}) | |
| 207 | + | d.beforeCopyModeRevalidate = func() { | |
| 208 | + | close(reached) | |
| 209 | + | <-resume | |
| 210 | + | } | |
| 211 | + | resumed := false | |
| 212 | + | defer func() { | |
| 213 | + | if !resumed { | |
| 214 | + | close(resume) | |
| 215 | + | } | |
| 216 | + | d.beforeCopyModeRevalidate = nil | |
| 217 | + | }() | |
| 218 | + | done := make(chan struct{}) | |
| 219 | + | go func() { | |
| 220 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 221 | + | close(done) | |
| 222 | + | }() | |
| 223 | + | select { | |
| 224 | + | case <-reached: | |
| 225 | + | case <-time.After(time.Second): | |
| 226 | + | t.Fatal("copy mode publication did not reach revalidation gate") | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | tc.invalidate(t, d, sess, ac, tb, p) | |
| 230 | + | close(resume) | |
| 231 | + | resumed = true | |
| 232 | + | select { | |
| 233 | + | case <-done: | |
| 234 | + | case <-time.After(time.Second): | |
| 235 | + | t.Fatal("copy mode publication did not finish after revalidation gate was released") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | ac.overlays.copyMu.Lock() | |
| 239 | + | require.Nil(t, ac.overlays.copyMode, "stale publication must not resurrect copy mode") | |
| 240 | + | require.Nil(t, ac.overlays.copyCandidate) | |
| 241 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 242 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 243 | + | ac.overlays.copyMu.Unlock() | |
| 244 | + | }) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestCopyModeLifecycleFloatingCloseDuringPublicationDoesNotResurrect(t *testing.T) { | |
| 249 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |
| 250 | + | defer func() { | |
| 251 | + | for _, release := range releases { | |
| 252 | + | release() | |
| 253 | + | } | |
| 254 | + | }() | |
| 255 | + | tb := sess.activeTab() | |
| 256 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 257 | + | installTestFloating(tb, floating, true) | |
| 258 | + | tb.mu.Lock() | |
| 259 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 260 | + | tb.mu.Unlock() | |
| 261 | + | require.True(t, visible) | |
| 262 | + | press := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2) | |
| 263 | + | motion := fmt.Appendf(nil, "\x1b[<32;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+3) | |
| 264 | + | ||
| 265 | + | d.handleInput(sess, ac, press) | |
| 266 | + | ac.overlays.copyMu.Lock() | |
| 267 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 268 | + | ac.overlays.copyMu.Unlock() | |
| 269 | + | ||
| 270 | + | reached := make(chan struct{}) | |
| 271 | + | resume := make(chan struct{}) | |
| 272 | + | d.beforeCopyModeRevalidate = func() { | |
| 273 | + | close(reached) | |
| 274 | + | <-resume | |
| 275 | + | } | |
| 276 | + | resumed := false | |
| 277 | + | defer func() { | |
| 278 | + | if !resumed { | |
| 279 | + | close(resume) | |
| 280 | + | } | |
| 281 | + | d.beforeCopyModeRevalidate = nil | |
| 282 | + | }() | |
| 283 | + | done := make(chan struct{}) | |
| 284 | + | go func() { | |
| 285 | + | d.handleInput(sess, ac, motion) | |
| 286 | + | close(done) | |
| 287 | + | }() | |
| 288 | + | select { | |
| 289 | + | case <-reached: | |
| 290 | + | case <-time.After(time.Second): | |
| 291 | + | t.Fatal("copy mode publication did not reach revalidation gate") | |
| 292 | + | } | |
| 293 | + | d.teardownFloating(tb, ac) | |
| 294 | + | close(resume) | |
| 295 | + | resumed = true | |
| 296 | + | select { | |
| 297 | + | case <-done: | |
| 298 | + | case <-time.After(time.Second): | |
| 299 | + | t.Fatal("copy mode publication did not finish after revalidation gate was released") | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | ac.overlays.copyMu.Lock() | |
| 303 | + | require.Nil(t, ac.overlays.copyMode) | |
| 304 | + | require.Nil(t, ac.overlays.copyCandidate) | |
| 305 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 306 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 307 | + | ac.overlays.copyMu.Unlock() | |
| 308 | + | } | |
| 309 | + | ||
| 130 | 310 | func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) { | |
| 131 | 311 | for _, tc := range []struct { | |
| 132 | 312 | name string | |
| @@ -157,11 +337,11 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 157 | 337 | tb := sess.activeTab() | |
| 158 | 338 | p := tb.focusedPane() | |
| 159 | 339 | p.mu.Lock() | |
| 160 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 340 | + | document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 161 | 341 | p.mu.Unlock() | |
| 162 | 342 | tc.invalidate(sess, tb, p) | |
| 163 | 343 | ||
| 164 | - | require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil)) | |
| 344 | + | require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil, nil)) | |
| 165 | 345 | require.False(t, ac.overlays.copyActive()) | |
| 166 | 346 | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 167 | 347 | }) | |
A internal/usecase/daemon/copymode_mouse_test.go +372 -0
| @@ -0,0 +1,372 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type manualMouseClock struct{ now time.Time } | |
| 18 | + | ||
| 19 | + | func (c *manualMouseClock) Now() time.Time { return c.now } | |
| 20 | + | func (c *manualMouseClock) Advance(d time.Duration) { c.now = c.now.Add(d) } | |
| 21 | + | func (*manualMouseClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |
| 22 | + | ||
| 23 | + | func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attachedClient, *manualMouseClock) { | |
| 24 | + | t.Helper() | |
| 25 | + | p, release := newBlockingPTY(t) | |
| 26 | + | t.Cleanup(release) | |
| 27 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 28 | + | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 29 | + | d.clock = clock | |
| 30 | + | pane := sess.activeTab().focusedPane() | |
| 31 | + | for i, row := range rows { | |
| 32 | + | copy(pane.screen.Frame.Row(i), testRow(row)) | |
| 33 | + | } | |
| 34 | + | d.enterCopyMode(sess, ac) | |
| 35 | + | return d, sess, ac, clock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func copyMouseInput(d *Daemon, sess *session, ac *attachedClient, raw string) { | |
| 39 | + | d.handleInput(sess, ac, []byte(raw)) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestCopyDoubleClick(t *testing.T) { | |
| 43 | + | t.Run("same normalized position within interval selects word", func(t *testing.T) { | |
| 44 | + | d, sess, ac, clock := mouseCopyHarness(t, "a界 beta") | |
| 45 | + | pane := sess.activeTab().focusedPane() | |
| 46 | + | d.exitCopyMode(ac) | |
| 47 | + | pane.mu.Lock() | |
| 48 | + | row := pane.screen.Frame.Row(0) | |
| 49 | + | row[2] = renderer.Cell{Continuation: true} | |
| 50 | + | pane.mu.Unlock() | |
| 51 | + | d.enterCopyMode(sess, ac) | |
| 52 | + | ||
| 53 | + | // SGR column 3 is the continuation cell of the wide glyph at column 2. | |
| 54 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<0;3;2m") | |
| 55 | + | clock.Advance(copyDoubleClickInterval) | |
| 56 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M") | |
| 57 | + | ||
| 58 | + | ac.overlays.copyMu.Lock() | |
| 59 | + | selection := ac.overlays.copyMode.Selection() | |
| 60 | + | pointer := ac.overlays.copyPointer | |
| 61 | + | ac.overlays.copyMu.Unlock() | |
| 62 | + | require.True(t, selection.Enabled) | |
| 63 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 64 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 1}, pointer.press) | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | for _, tc := range []struct { | |
| 68 | + | name string | |
| 69 | + | advance time.Duration | |
| 70 | + | second string | |
| 71 | + | }{ | |
| 72 | + | {name: "expired", advance: copyDoubleClickInterval + time.Nanosecond, second: "\x1b[<0;1;2M"}, | |
| 73 | + | {name: "different position", second: "\x1b[<0;2;2M"}, | |
| 74 | + | } { | |
| 75 | + | t.Run(tc.name+" stays passive", func(t *testing.T) { | |
| 76 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha") | |
| 77 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 78 | + | clock.Advance(tc.advance) | |
| 79 | + | copyMouseInput(d, sess, ac, tc.second) | |
| 80 | + | ac.overlays.copyMu.Lock() | |
| 81 | + | selection := ac.overlays.copyMode.Selection() | |
| 82 | + | ac.overlays.copyMu.Unlock() | |
| 83 | + | require.False(t, selection.Enabled) | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | t.Run("different pane is not a double click", func(t *testing.T) { | |
| 88 | + | d := newTestDaemon(t, nil, &manualMouseClock{now: time.Unix(1, 0)}) | |
| 89 | + | rt := newOverlayRuntime(&attachedClient{}) | |
| 90 | + | p1 := newPane("one", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 91 | + | p2 := newPane("two", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 92 | + | rt.copyClick = copyClickCandidate{valid: true, pane: p1, pos: scopy.Pos{Row: 0, Col: 1}, at: d.clock.Now()} | |
| 93 | + | require.False(t, d.isCopyDoubleClickLocked(rt, p2, scopy.Pos{Row: 0, Col: 1}, d.clock.Now())) | |
| 94 | + | }) | |
| 95 | + | ||
| 96 | + | for _, text := range []string{"alpha beta", "alpha/beta"} { | |
| 97 | + | t.Run("separator is passive "+text, func(t *testing.T) { | |
| 98 | + | d, sess, ac, clock := mouseCopyHarness(t, text) | |
| 99 | + | if text == "alpha/beta" { | |
| 100 | + | cfg := domain.Defaults() | |
| 101 | + | cfg.Copy.WordSeparators = "/" | |
| 102 | + | d.ApplyConfig(cfg) | |
| 103 | + | d.exitCopyMode(ac) | |
| 104 | + | d.enterCopyMode(sess, ac) | |
| 105 | + | } | |
| 106 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m") | |
| 107 | + | clock.Advance(time.Millisecond) | |
| 108 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M") | |
| 109 | + | ac.overlays.copyMu.Lock() | |
| 110 | + | selection := ac.overlays.copyMode.Selection() | |
| 111 | + | ac.overlays.copyMu.Unlock() | |
| 112 | + | require.False(t, selection.Enabled) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | t.Run("normal screen double click publishes word drag", func(t *testing.T) { | |
| 117 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta") | |
| 118 | + | d.exitCopyMode(ac) | |
| 119 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 120 | + | clock.Advance(time.Millisecond) | |
| 121 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;7;2M") | |
| 122 | + | ac.overlays.copyMu.Lock() | |
| 123 | + | selection := ac.overlays.copyMode.Selection() | |
| 124 | + | text := ac.overlays.copyMode.SelectedText() | |
| 125 | + | pointer := ac.overlays.copyPointer | |
| 126 | + | ac.overlays.copyMu.Unlock() | |
| 127 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 128 | + | require.True(t, pointer.wordDrag) | |
| 129 | + | require.Equal(t, "alpha beta", text) | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | t.Run("first click drag invalidates candidate", func(t *testing.T) { | |
| 133 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha") | |
| 134 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;2M\x1b[<0;2;2m") | |
| 135 | + | clock.Advance(time.Millisecond) | |
| 136 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |
| 137 | + | ac.overlays.copyMu.Lock() | |
| 138 | + | selection := ac.overlays.copyMode.Selection() | |
| 139 | + | ac.overlays.copyMu.Unlock() | |
| 140 | + | require.NotEqual(t, scopy.Word, selection.Granularity) | |
| 141 | + | }) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestCopyWordDragKeepsWordAnchorOnFirstMotion(t *testing.T) { | |
| 145 | + | for _, tc := range []struct { | |
| 146 | + | name string | |
| 147 | + | pressCol, motionCol int | |
| 148 | + | wantAnchor, wantActive scopy.Pos | |
| 149 | + | }{ | |
| 150 | + | { | |
| 151 | + | name: "forward from inside first word", | |
| 152 | + | pressCol: 3, | |
| 153 | + | motionCol: 8, | |
| 154 | + | wantAnchor: scopy.Pos{Row: 0}, | |
| 155 | + | wantActive: scopy.Pos{Row: 0, Col: 9}, | |
| 156 | + | }, | |
| 157 | + | { | |
| 158 | + | name: "reverse from inside second word", | |
| 159 | + | pressCol: 8, | |
| 160 | + | motionCol: 3, | |
| 161 | + | wantAnchor: scopy.Pos{Row: 0, Col: 6}, | |
| 162 | + | wantActive: scopy.Pos{Row: 0, Col: 4}, | |
| 163 | + | }, | |
| 164 | + | } { | |
| 165 | + | t.Run(tc.name, func(t *testing.T) { | |
| 166 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta") | |
| 167 | + | copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2m") | |
| 168 | + | clock.Advance(time.Millisecond) | |
| 169 | + | copyMouseInput(d, sess, ac, "\x1b[<0;"+strconv.Itoa(tc.pressCol)+";2M\x1b[<32;"+strconv.Itoa(tc.motionCol)+";2M") | |
| 170 | + | ||
| 171 | + | ac.overlays.copyMu.Lock() | |
| 172 | + | selection := ac.overlays.copyMode.Selection() | |
| 173 | + | text := ac.overlays.copyMode.SelectedText() | |
| 174 | + | pointer := ac.overlays.copyPointer | |
| 175 | + | ac.overlays.copyMu.Unlock() | |
| 176 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 177 | + | require.Equal(t, tc.wantAnchor, selection.Anchor) | |
| 178 | + | require.Equal(t, tc.wantActive, selection.Active) | |
| 179 | + | require.Equal(t, "alpha beta", text) | |
| 180 | + | require.True(t, pointer.dragging) | |
| 181 | + | require.True(t, pointer.wordDrag) | |
| 182 | + | }) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func TestCopyCharacterDragStartsAtPress(t *testing.T) { | |
| 187 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha beta") | |
| 188 | + | copyMouseInput(d, sess, ac, "\x1b[<0;3;2M\x1b[<32;8;2M") | |
| 189 | + | ||
| 190 | + | ac.overlays.copyMu.Lock() | |
| 191 | + | selection := ac.overlays.copyMode.Selection() | |
| 192 | + | pointer := ac.overlays.copyPointer | |
| 193 | + | ac.overlays.copyMu.Unlock() | |
| 194 | + | require.Equal(t, scopy.Character, selection.Granularity) | |
| 195 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 2}, selection.Anchor) | |
| 196 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active) | |
| 197 | + | require.True(t, pointer.dragging) | |
| 198 | + | require.False(t, pointer.wordDrag) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) { | |
| 202 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |
| 203 | + | ||
| 204 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |
| 205 | + | ac.overlays.copyMu.Lock() | |
| 206 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 207 | + | epoch := ac.overlays.copyPointerEpoch | |
| 208 | + | ac.overlays.copyMu.Unlock() | |
| 209 | + | ||
| 210 | + | copyMouseInput(d, sess, ac, "/") | |
| 211 | + | require.True(t, ac.overlays.copySearchActive()) | |
| 212 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2m") | |
| 213 | + | ||
| 214 | + | ac.overlays.copyMu.Lock() | |
| 215 | + | require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input") | |
| 216 | + | require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch") | |
| 217 | + | ac.overlays.copyMu.Unlock() | |
| 218 | + | ||
| 219 | + | copyMouseInput(d, sess, ac, "\x03") | |
| 220 | + | require.False(t, ac.overlays.copySearchActive()) | |
| 221 | + | copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M") | |
| 222 | + | ||
| 223 | + | ac.overlays.copyMu.Lock() | |
| 224 | + | selection := ac.overlays.copyMode.Selection() | |
| 225 | + | ac.overlays.copyMu.Unlock() | |
| 226 | + | require.True(t, selection.Enabled) | |
| 227 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor) | |
| 228 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) { | |
| 232 | + | for _, tc := range []struct { | |
| 233 | + | name string | |
| 234 | + | text string | |
| 235 | + | setup func(*Daemon) | |
| 236 | + | }{ | |
| 237 | + | {name: "whitespace", text: "alpha beta"}, | |
| 238 | + | {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) { | |
| 239 | + | cfg := domain.Defaults() | |
| 240 | + | cfg.Copy.WordSeparators = "/" | |
| 241 | + | d.ApplyConfig(cfg) | |
| 242 | + | }}, | |
| 243 | + | } { | |
| 244 | + | t.Run(tc.name, func(t *testing.T) { | |
| 245 | + | d, sess, ac, clock := mouseCopyHarness(t, tc.text) | |
| 246 | + | d.exitCopyMode(ac) | |
| 247 | + | if tc.setup != nil { | |
| 248 | + | tc.setup(d) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m") | |
| 252 | + | clock.Advance(time.Millisecond) | |
| 253 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M") | |
| 254 | + | ||
| 255 | + | ac.overlays.copyMu.Lock() | |
| 256 | + | mode := ac.overlays.copyMode | |
| 257 | + | selection := mode.Selection() | |
| 258 | + | cursor := mode.Cursor() | |
| 259 | + | ac.overlays.copyMu.Unlock() | |
| 260 | + | require.False(t, selection.Enabled) | |
| 261 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor) | |
| 262 | + | }) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestCopyWordDragAndOSC52(t *testing.T) { | |
| 267 | + | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta") | |
| 268 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 269 | + | clock.Advance(time.Millisecond) | |
| 270 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;2;3M\x1b[<0;2;3m") | |
| 271 | + | ||
| 272 | + | ac.overlays.copyMu.Lock() | |
| 273 | + | mode := ac.overlays.copyMode | |
| 274 | + | selection := mode.Selection() | |
| 275 | + | text := mode.SelectedText() | |
| 276 | + | width := mode.Document().Width() | |
| 277 | + | ac.overlays.copyMu.Unlock() | |
| 278 | + | want := "alpha beta" + strings.Repeat(" ", width-len("alpha beta")) + "\ngamma" | |
| 279 | + | require.Equal(t, scopy.Word, selection.Granularity) | |
| 280 | + | require.Equal(t, want, text) | |
| 281 | + | require.Equal(t, scopy.OSC52(want)[0], scopy.OSC52(text)[0]) | |
| 282 | + | ||
| 283 | + | copyMouseInput(d, sess, ac, "y") | |
| 284 | + | require.False(t, ac.overlays.copyActive()) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func seedCopyInteractionLocked(rt *overlayRuntime, p *pane, doc *scopy.Document) { | |
| 288 | + | rt.beginCopyPointerLocked(copyPointerState{pane: p, document: doc}) | |
| 289 | + | rt.copyClick = copyClickCandidate{valid: true, pane: p} | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestCopyPointerResetClearsClick(t *testing.T) { | |
| 293 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 294 | + | doc := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("word")}, 4, 1), domain.DefaultWordSeparators) | |
| 295 | + | for _, tc := range []struct { | |
| 296 | + | name string | |
| 297 | + | reset func(*overlayRuntime) | |
| 298 | + | holdsCopyMu bool | |
| 299 | + | }{ | |
| 300 | + | {name: "clear mode", reset: func(rt *overlayRuntime) { rt.clearCopyModeLocked() }, holdsCopyMu: true}, | |
| 301 | + | {name: "replace publication", reset: func(rt *overlayRuntime) { rt.invalidateCopyPointerLocked(true) }, holdsCopyMu: true}, | |
| 302 | + | {name: "pane close", reset: func(rt *overlayRuntime) { rt.clearCopyModeForPane(p) }}, | |
| 303 | + | } { | |
| 304 | + | t.Run(tc.name, func(t *testing.T) { | |
| 305 | + | rt := newOverlayRuntime(&attachedClient{}) | |
| 306 | + | rt.copyMu.Lock() | |
| 307 | + | rt.copyPane, rt.copyDocument, rt.copyMode = p, doc, scopy.NewMode(doc) | |
| 308 | + | seedCopyInteractionLocked(rt, p, doc) | |
| 309 | + | if !tc.holdsCopyMu { | |
| 310 | + | rt.copyMu.Unlock() | |
| 311 | + | tc.reset(rt) | |
| 312 | + | rt.copyMu.Lock() | |
| 313 | + | } else { | |
| 314 | + | tc.reset(rt) | |
| 315 | + | } | |
| 316 | + | require.False(t, rt.copyPointer.valid) | |
| 317 | + | require.False(t, rt.copyClick.valid) | |
| 318 | + | rt.copyMu.Unlock() | |
| 319 | + | }) | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func TestCopyWordSelectionYanksExactOSC52(t *testing.T) { | |
| 324 | + | p, release := newBlockingPTY(t) | |
| 325 | + | defer release() | |
| 326 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 327 | + | clock := &manualMouseClock{now: time.Unix(1, 0)} | |
| 328 | + | d.clock = clock | |
| 329 | + | copy(sess.activeTab().focusedPane().screen.Frame.Row(0), testRow("alpha beta")) | |
| 330 | + | d.enterCopyMode(sess, ac) | |
| 331 | + | mustOutputData(t, sends) | |
| 332 | + | ||
| 333 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| 334 | + | clock.Advance(time.Millisecond) | |
| 335 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |
| 336 | + | mustOutputData(t, sends) | |
| 337 | + | copyMouseInput(d, sess, ac, "y") | |
| 338 | + | // The selection repaint can have been queued before the synchronous OSC 52 | |
| 339 | + | // output; consume it, then assert the wire payload itself byte-for-byte. | |
| 340 | + | mustOutputData(t, sends) | |
| 341 | + | require.Equal(t, scopy.OSC52("alpha")[0], mustOutputData(t, sends)) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) { | |
| 345 | + | for _, tc := range []struct { | |
| 346 | + | name string | |
| 347 | + | run func(*Daemon, *session, *attachedClient) | |
| 348 | + | }{ | |
| 349 | + | {name: "q", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) }}, | |
| 350 | + | {name: "escape", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("\x1bx")) }}, | |
| 351 | + | {name: "yank", run: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("y")) }}, | |
| 352 | + | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 353 | + | p := sess.activeTab().focusedPane() | |
| 354 | + | p.mu.Lock() | |
| 355 | + | doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators) | |
| 356 | + | p.mu.Unlock() | |
| 357 | + | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 358 | + | }}, | |
| 359 | + | } { | |
| 360 | + | t.Run(tc.name, func(t *testing.T) { | |
| 361 | + | d, sess, ac, _ := mouseCopyHarness(t, "word") | |
| 362 | + | ac.overlays.copyMu.Lock() | |
| 363 | + | seedCopyInteractionLocked(ac.overlays, ac.overlays.copyPane, ac.overlays.copyDocument) | |
| 364 | + | ac.overlays.copyMu.Unlock() | |
| 365 | + | tc.run(d, sess, ac) | |
| 366 | + | ac.overlays.copyMu.Lock() | |
| 367 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 368 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 369 | + | ac.overlays.copyMu.Unlock() | |
| 370 | + | }) | |
| 371 | + | } | |
| 372 | + | } | |
M internal/usecase/daemon/copymode_test.go +24 -22
| @@ -114,7 +114,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 114 | 114 | pane.mu.Lock() | |
| 115 | 115 | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | - | mode := scopy.NewMode(snap) | |
| 117 | + | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | 118 | bars := barState{status: sess.statusSegments(true)} | |
| 119 | 119 | ||
| 120 | 120 | base := renderer.NewFrame(80, 25) | |
| @@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 133 | 133 | go func() { | |
| 134 | 134 | defer wg.Done() | |
| 135 | 135 | for range 200 { | |
| 136 | - | _, _ = composeCopyClientFrame(mode, &snap, target, base, bars) | |
| 136 | + | _, _ = composeCopyClientFrame(mode, target, base, bars) | |
| 137 | 137 | } | |
| 138 | 138 | }() | |
| 139 | 139 | wg.Wait() | |
| @@ -148,7 +148,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 148 | 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 149 | 149 | tb.focusedPane().screen.Write([]byte("live")) | |
| 150 | 150 | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) | |
| 151 | - | mode := scopy.NewMode(snap) | |
| 151 | + | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 152 | 152 | ||
| 153 | 153 | bars := barState{status: sess.statusSegments(true)} | |
| 154 | 154 | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| @@ -160,7 +160,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 160 | 160 | }}, | |
| 161 | 161 | bars: bars, | |
| 162 | 162 | }, composeCacheInput{}).frame | |
| 163 | - | frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 163 | + | frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 164 | 164 | ||
| 165 | 165 | require.Equal(t, 80, frame.Width) | |
| 166 | 166 | require.Equal(t, 25, frame.Height) | |
| @@ -220,7 +220,7 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) { | |||
| 220 | 220 | ||
| 221 | 221 | require.Nil(t, ac.overlays.copySearch) | |
| 222 | 222 | require.NotNil(t, ac.overlays.copyMode) | |
| 223 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor) | |
| 223 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row) | |
| 224 | 224 | select { | |
| 225 | 225 | case got := <-writes: | |
| 226 | 226 | t.Fatalf("batched visual search input forwarded to PTY: %q", got) | |
| @@ -257,14 +257,14 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) { | |||
| 257 | 257 | d.handleInput(sess, ac, []byte("\r")) | |
| 258 | 258 | awaitFrame(t, sends, ports.MsgOutput) | |
| 259 | 259 | require.Nil(t, ac.overlays.copySearch) | |
| 260 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor) | |
| 260 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row) | |
| 261 | 261 | ||
| 262 | 262 | d.handleInput(sess, ac, []byte("n")) | |
| 263 | 263 | awaitFrame(t, sends, ports.MsgOutput) | |
| 264 | - | require.Equal(t, 1, ac.overlays.copyMode.Cursor) | |
| 264 | + | require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row) | |
| 265 | 265 | d.handleInput(sess, ac, []byte("N")) | |
| 266 | 266 | awaitFrame(t, sends, ports.MsgOutput) | |
| 267 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor) | |
| 267 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row) | |
| 268 | 268 | ||
| 269 | 269 | select { | |
| 270 | 270 | case got := <-writes: | |
| @@ -289,12 +289,12 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) { | |||
| 289 | 289 | d.handleInput(sess, ac, []byte("alpha")) | |
| 290 | 290 | awaitFrame(t, sends, ports.MsgOutput) | |
| 291 | 291 | require.NotNil(t, ac.overlays.copySearch) | |
| 292 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal") | |
| 292 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row, "typing a query previews the selected first result behind the modal") | |
| 293 | 293 | ||
| 294 | 294 | d.handleInput(sess, ac, []byte{0x0e}) | |
| 295 | 295 | awaitFrame(t, sends, ports.MsgOutput) | |
| 296 | 296 | require.NotNil(t, ac.overlays.copySearch) | |
| 297 | - | require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter") | |
| 297 | + | require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row, "moving modal selection previews that result without Enter") | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | 300 | func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |
| @@ -310,11 +310,11 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 310 | 310 | d.handleInput(sess, ac, []byte("/alpha")) | |
| 311 | 311 | awaitFrame(t, sends, ports.MsgOutput) | |
| 312 | 312 | require.NotNil(t, ac.overlays.copySearch) | |
| 313 | - | cursor := ac.overlays.copyMode.Cursor | |
| 313 | + | cursor := ac.overlays.copyMode.Cursor().Row | |
| 314 | 314 | ||
| 315 | 315 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 316 | 316 | require.NotNil(t, ac.overlays.copySearch) | |
| 317 | - | require.Equal(t, cursor, ac.overlays.copyMode.Cursor) | |
| 317 | + | require.Equal(t, cursor, ac.overlays.copyMode.Cursor().Row) | |
| 318 | 318 | ||
| 319 | 319 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 320 | 320 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -517,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 517 | 517 | } | |
| 518 | 518 | ||
| 519 | 519 | require.NotNil(t, ac.overlays.copyMode) | |
| 520 | - | require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor) | |
| 520 | + | require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor().Row) | |
| 521 | 521 | }) | |
| 522 | 522 | } | |
| 523 | 523 | } | |
| @@ -790,11 +790,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 790 | 790 | ||
| 791 | 791 | d.enterCopyMode(sess, ac) | |
| 792 | 792 | awaitFrame(t, sends, ports.MsgOutput) | |
| 793 | - | require.Equal(t, total-1, ac.overlays.copyMode.Cursor) | |
| 793 | + | require.Equal(t, total-1, ac.overlays.copyMode.Cursor().Row) | |
| 794 | 794 | ||
| 795 | 795 | d.copyWheel(sess, ac, -3) | |
| 796 | 796 | awaitFrame(t, sends, ports.MsgOutput) | |
| 797 | - | require.Equal(t, total-4, ac.overlays.copyMode.Cursor) | |
| 797 | + | require.Equal(t, total-4, ac.overlays.copyMode.Cursor().Row) | |
| 798 | 798 | ||
| 799 | 799 | d.copyWheel(sess, ac, 3) | |
| 800 | 800 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -813,7 +813,9 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 813 | 813 | ||
| 814 | 814 | d.enterCopyMode(sess, ac) | |
| 815 | 815 | awaitFrame(t, sends, ports.MsgOutput) | |
| 816 | + | ac.overlays.copyMu.Lock() | |
| 816 | 817 | viewportTop := ac.overlays.copyMode.ViewportTop | |
| 818 | + | ac.overlays.copyMu.Unlock() | |
| 817 | 819 | inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner | |
| 818 | 820 | ||
| 819 | 821 | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | |
| @@ -821,15 +823,15 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 821 | 823 | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3) | |
| 822 | 824 | d.handleInput(sess, ac, []byte(press)) | |
| 823 | 825 | awaitFrame(t, sends, ports.MsgOutput) | |
| 824 | - | require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor) | |
| 826 | + | require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor().Row) | |
| 825 | 827 | ||
| 826 | 828 | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4) | |
| 827 | 829 | d.handleInput(sess, ac, []byte(motion)) | |
| 828 | 830 | awaitFrame(t, sends, ports.MsgOutput) | |
| 829 | - | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 830 | - | require.True(t, ok) | |
| 831 | - | require.Equal(t, viewportTop+1, lo) | |
| 832 | - | require.Equal(t, viewportTop+2, hi) | |
| 831 | + | selection := ac.overlays.copyMode.Selection() | |
| 832 | + | require.True(t, selection.Enabled) | |
| 833 | + | require.Equal(t, viewportTop+1, selection.Anchor.Row) | |
| 834 | + | require.Equal(t, viewportTop+2, selection.Active.Row) | |
| 833 | 835 | } | |
| 834 | 836 | ||
| 835 | 837 | func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |
| @@ -864,7 +866,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 864 | 866 | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |
| 865 | 867 | copy(pane.screen.Frame.Row(1), testRow("bravo")) | |
| 866 | 868 | ||
| 867 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 869 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 868 | 870 | mustOutputData(t, sends) | |
| 869 | 871 | require.NotNil(t, ac.overlays.copyMode) | |
| 870 | 872 | ac.overlays.copyMu.Lock() | |
| @@ -876,6 +878,6 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 876 | 878 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 877 | 879 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 878 | 880 | require.NoError(t, err) | |
| 879 | - | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07" | |
| 881 | + | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha"+strings.Repeat(" ", 75)+"\nb")) + "\x07" | |
| 880 | 882 | require.Equal(t, want, string(msg.Data)) | |
| 881 | 883 | } | |
M internal/usecase/daemon/daemon.go +30 -21
| @@ -91,27 +91,33 @@ type Daemon struct { | |||
| 91 | 91 | // beforeRecentSessionHandoff is a deterministic test seam for the narrow | |
| 92 | 92 | // interval between JRS validation and its committed hand-off. | |
| 93 | 93 | beforeRecentSessionHandoff func() | |
| 94 | - | ptys ports.PTYFactory | |
| 95 | - | clock ports.Clock | |
| 96 | - | log *slog.Logger | |
| 97 | - | runtimeObserver ports.RuntimeObserver | |
| 98 | - | baseEnv []string | |
| 99 | - | shell string | |
| 100 | - | shellArgs []string | |
| 101 | - | shellOverride bool | |
| 102 | - | persist *persist.Persister | |
| 103 | - | persistEnabled bool | |
| 104 | - | snaps ports.SnapshotStore | |
| 105 | - | snapsEnabled bool | |
| 106 | - | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 107 | - | snapshotJobs chan *snapshotCapture | |
| 108 | - | snapshotWorkerMu sync.Mutex | |
| 109 | - | snapshotWorkerID uint64 | |
| 110 | - | snapshotWorkerCtx context.Context | |
| 111 | - | snapshotWorkerCancel context.CancelFunc | |
| 112 | - | snapshotWorkerDone chan struct{} | |
| 113 | - | snapshotWorkerFlush chan struct{} | |
| 114 | - | snapshotWorkerFinalWake chan struct{} | |
| 94 | + | // beforeCopyModeRevalidate is a deterministic test seam between staging a | |
| 95 | + | // copy-mode candidate and revalidating its pane membership. | |
| 96 | + | beforeCopyModeRevalidate func() | |
| 97 | + | // beforeCopyMouseMap is a deterministic seam after an immutable copy-input | |
| 98 | + | // snapshot is captured and before its mapped position is applied. | |
| 99 | + | beforeCopyMouseMap func() | |
| 100 | + | ptys ports.PTYFactory | |
| 101 | + | clock ports.Clock | |
| 102 | + | log *slog.Logger | |
| 103 | + | runtimeObserver ports.RuntimeObserver | |
| 104 | + | baseEnv []string | |
| 105 | + | shell string | |
| 106 | + | shellArgs []string | |
| 107 | + | shellOverride bool | |
| 108 | + | persist *persist.Persister | |
| 109 | + | persistEnabled bool | |
| 110 | + | snaps ports.SnapshotStore | |
| 111 | + | snapsEnabled bool | |
| 112 | + | snapshotMarshal func(snapcodec.Session) ([]byte, error) | |
| 113 | + | snapshotJobs chan *snapshotCapture | |
| 114 | + | snapshotWorkerMu sync.Mutex | |
| 115 | + | snapshotWorkerID uint64 | |
| 116 | + | snapshotWorkerCtx context.Context | |
| 117 | + | snapshotWorkerCancel context.CancelFunc | |
| 118 | + | snapshotWorkerDone chan struct{} | |
| 119 | + | snapshotWorkerFlush chan struct{} | |
| 120 | + | snapshotWorkerFinalWake chan struct{} | |
| 115 | 121 | // snapshotFinalJobs coalesces terminal captures by session when the bounded | |
| 116 | 122 | // regular queue is full. It retains at most snapshotFinalQueueCapacity named | |
| 117 | 123 | // sessions, each with only its newest terminal state while the worker blocks. | |
| @@ -130,6 +136,7 @@ type Daemon struct { | |||
| 130 | 136 | codeOverrides atomic.Pointer[map[string]string] | |
| 131 | 137 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 132 | 138 | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 139 | + | copyConfig atomic.Pointer[domain.CopyConfig] | |
| 133 | 140 | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 134 | 141 | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 135 | 142 | themeMode atomic.Uint32 | |
| @@ -331,6 +338,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 331 | 338 | defaults := domain.Defaults() | |
| 332 | 339 | defaultFloating := defaults.Floating | |
| 333 | 340 | d.floatingConfig.Store(&defaultFloating) | |
| 341 | + | defaultCopy := defaults.Copy | |
| 342 | + | d.copyConfig.Store(&defaultCopy) | |
| 334 | 343 | defaultPalette := defaults.Palette | |
| 335 | 344 | d.paletteConfig.Store(&defaultPalette) | |
| 336 | 345 | for _, o := range opts { | |
M internal/usecase/daemon/input.go +24 -128
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | ||
| 8 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 8 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 10 | "github.com/bnema/vev/internal/usecase/mouse" | |
| @@ -33,6 +32,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 33 | 32 | } | |
| 34 | 33 | ||
| 35 | 34 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 35 | + | frameEvent := ev | |
| 36 | 36 | ac.initOverlays() | |
| 37 | 37 | rt := ac.overlays | |
| 38 | 38 | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | |
| @@ -40,82 +40,21 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 40 | 40 | } | |
| 41 | 41 | sess := ac.currentSession() | |
| 42 | 42 | if sess == nil { | |
| 43 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 43 | 44 | return | |
| 44 | 45 | } | |
| 45 | 46 | tb := sess.activeTab() | |
| 46 | 47 | if tb == nil { | |
| 48 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 47 | 49 | return | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | - | if rt.copyActive() { | |
| 51 | - | if rt.copySearchActive() { | |
| 52 | - | return | |
| 53 | - | } | |
| 54 | - | copyPane := copyTargetPane(rt) | |
| 55 | - | tb.mu.Lock() | |
| 56 | - | floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 57 | - | floatingCopy := floatingVisible && copyPane == floatingPane | |
| 58 | - | tb.mu.Unlock() | |
| 59 | - | if floatingCopy { | |
| 60 | - | contentRow := ev.Row - 1 | |
| 61 | - | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | |
| 62 | - | return | |
| 63 | - | } | |
| 64 | - | // Match the multi-pane translation: hand copyMouse a zero-based | |
| 65 | - | // popup row so a click selects exactly the row under the pointer. | |
| 66 | - | ev.Row = contentRow | |
| 67 | - | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | |
| 68 | - | switch ev.Button { | |
| 69 | - | case mouse.Left: | |
| 70 | - | d.copyMouse(sess, ac, ev) | |
| 71 | - | case mouse.WheelUp: | |
| 72 | - | d.copyWheel(sess, ac, -3) | |
| 73 | - | case mouse.WheelDown: | |
| 74 | - | d.copyWheel(sess, ac, 3) | |
| 75 | - | } | |
| 76 | - | return | |
| 77 | - | } | |
| 78 | - | if ev.Button == mouse.Left { | |
| 79 | - | contentRow := ev.Row - 1 | |
| 80 | - | tb.mu.Lock() | |
| 81 | - | p := tb.focusedPane() | |
| 82 | - | var pl layout.Placement | |
| 83 | - | var ok bool | |
| 84 | - | multi := len(tb.panes) > 1 | |
| 85 | - | if p != nil { | |
| 86 | - | pl, ok = focusedPlacementLocked(tb) | |
| 87 | - | } | |
| 88 | - | if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press { | |
| 89 | - | if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit { | |
| 90 | - | oldFocus := tb.tree.Focus | |
| 91 | - | focusPlacementLocked(tb, target.ID) | |
| 92 | - | d.applyLayoutLocked(tb) | |
| 93 | - | tb.mu.Unlock() | |
| 94 | - | d.exitCopyMode(ac) | |
| 95 | - | if target.ID != oldFocus { | |
| 96 | - | d.refreshPaneTitleOnFocus(sess, target.ID) | |
| 97 | - | } | |
| 98 | - | d.invalidateRender(sess, ac, true, "input.go") | |
| 99 | - | return | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | tb.mu.Unlock() | |
| 103 | - | if ok && multi { | |
| 104 | - | clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1) | |
| 105 | - | clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1) | |
| 106 | - | ev.Col = clampedCol | |
| 107 | - | ev.Row = clampedRow | |
| 108 | - | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | switch ev.Button { | |
| 112 | - | case mouse.Left: | |
| 113 | - | d.copyMouse(sess, ac, ev) | |
| 114 | - | case mouse.WheelUp: | |
| 115 | - | d.copyWheel(sess, ac, -3) | |
| 116 | - | case mouse.WheelDown: | |
| 117 | - | d.copyWheel(sess, ac, 3) | |
| 118 | - | } | |
| 52 | + | if d.handleCopyMouse(sess, ac, tb, ev) { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | // A fresh drag is pinned to its press geometry. Handle later events before | |
| 56 | + | // normal terminal routing so crossing a split cannot retarget its document. | |
| 57 | + | if ev.Button == mouse.Left && ev.Type != mouse.Press && d.handleFreshCopyPointer(sess, ac, ev) { | |
| 119 | 58 | return | |
| 120 | 59 | } | |
| 121 | 60 | ||
| @@ -125,9 +64,13 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 125 | 64 | if floatingVisible { | |
| 126 | 65 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| 127 | 66 | tb.mu.Unlock() | |
| 67 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 128 | 68 | return | |
| 129 | 69 | } | |
| 130 | 70 | tb.mu.Unlock() | |
| 71 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) { | |
| 72 | + | return | |
| 73 | + | } | |
| 131 | 74 | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |
| 132 | 75 | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | |
| 133 | 76 | return | |
| @@ -141,12 +84,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 141 | 84 | if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) { | |
| 142 | 85 | if !isMouseFocusPress(ev) { | |
| 143 | 86 | tb.mu.Unlock() | |
| 87 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 144 | 88 | return | |
| 145 | 89 | } | |
| 146 | 90 | oldFocus := focusedID | |
| 147 | 91 | focusPlacementLocked(tb, pl.ID) | |
| 148 | 92 | d.applyLayoutLocked(tb) | |
| 149 | 93 | tb.mu.Unlock() | |
| 94 | + | // A title bar never routes to terminal content. Clear any pre-existing | |
| 95 | + | // left-button candidate before handling the focus result, including when | |
| 96 | + | // this press leaves the same pane focused. | |
| 97 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 150 | 98 | if pl.ID != oldFocus { | |
| 151 | 99 | d.exitCopyMode(ac) | |
| 152 | 100 | d.refreshPaneTitleOnFocus(sess, pl.ID) | |
| @@ -167,6 +115,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 167 | 115 | hoveredFocused = pl.ID == oldFocus | |
| 168 | 116 | tb.mu.Unlock() | |
| 169 | 117 | if p == nil { | |
| 118 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 170 | 119 | return | |
| 171 | 120 | } | |
| 172 | 121 | if isMouseFocusPress(ev) && pl.ID != oldFocus { | |
| @@ -181,14 +130,19 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 181 | 130 | } else { | |
| 182 | 131 | if multi { | |
| 183 | 132 | tb.mu.Unlock() | |
| 133 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 184 | 134 | return | |
| 185 | 135 | } | |
| 186 | 136 | p = tb.focusedPane() | |
| 187 | 137 | tb.mu.Unlock() | |
| 188 | 138 | if p == nil { | |
| 139 | + | invalidateRejectedLeftPointer(rt, ev) | |
| 189 | 140 | return | |
| 190 | 141 | } | |
| 191 | 142 | } | |
| 143 | + | if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) { | |
| 144 | + | return | |
| 145 | + | } | |
| 192 | 146 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 193 | 147 | } | |
| 194 | 148 | ||
| @@ -196,15 +150,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 196 | 150 | if p == nil { | |
| 197 | 151 | return | |
| 198 | 152 | } | |
| 199 | - | rt := ac.overlays | |
| 200 | 153 | p.mu.Lock() | |
| 201 | 154 | childRows := p.screen.Frame.Height | |
| 202 | 155 | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 203 | 156 | altScreen := p.screen.AltScreenActive() | |
| 204 | - | scrollbackRows := 0 | |
| 205 | - | if p.history != nil { | |
| 206 | - | scrollbackRows = p.history.Len() | |
| 207 | - | } | |
| 208 | 157 | p.mu.Unlock() | |
| 209 | 158 | ||
| 210 | 159 | if mouseMode != 0 { | |
| @@ -220,59 +169,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 220 | 169 | } | |
| 221 | 170 | ||
| 222 | 171 | switch ev.Button { | |
| 223 | - | case mouse.Left: | |
| 224 | - | switch ev.Type { | |
| 225 | - | case mouse.Press: | |
| 226 | - | if altScreen || ev.Row >= childRows { | |
| 227 | - | rt.copyMu.Lock() | |
| 228 | - | rt.normalMousePressValid = false | |
| 229 | - | rt.copyMu.Unlock() | |
| 230 | - | return | |
| 231 | - | } | |
| 232 | - | rt.copyMu.Lock() | |
| 233 | - | rt.normalMousePressRow = ev.Row | |
| 234 | - | rt.normalMousePressTop = scrollbackRows | |
| 235 | - | rt.normalMousePressValid = true | |
| 236 | - | rt.copyMu.Unlock() | |
| 237 | - | case mouse.Motion: | |
| 238 | - | if altScreen || ev.Row >= childRows { | |
| 239 | - | return | |
| 240 | - | } | |
| 241 | - | rt.copyMu.Lock() | |
| 242 | - | pressValid := rt.normalMousePressValid | |
| 243 | - | pressRow := rt.normalMousePressRow | |
| 244 | - | pressTop := rt.normalMousePressTop | |
| 245 | - | rt.copyMu.Unlock() | |
| 246 | - | if !pressValid { | |
| 247 | - | return | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // The immutable snapshot is the only live-pane access needed for drag entry. | |
| 251 | - | p.mu.Lock() | |
| 252 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 253 | - | p.mu.Unlock() | |
| 254 | - | tb := sess.activeTab() | |
| 255 | - | if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) { | |
| 256 | - | mode.StartSelectionAt(document, pressTop+pressRow) | |
| 257 | - | mode.ExtendTo(document, document.Len()-document.Height+ev.Row) | |
| 258 | - | }) { | |
| 259 | - | return | |
| 260 | - | } | |
| 261 | - | rt.copyMu.Lock() | |
| 262 | - | // Publication is revalidated before this state is installed, and no | |
| 263 | - | // lifecycle lock is held while copyMu is taken. | |
| 264 | - | if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil { | |
| 265 | - | rt.copyPressRow = pressTop + pressRow | |
| 266 | - | rt.copyPressRowValid = true | |
| 267 | - | rt.copyDragging = true | |
| 268 | - | } | |
| 269 | - | rt.copyMu.Unlock() | |
| 270 | - | d.invalidateRender(sess, ac, true, "input.go") | |
| 271 | - | case mouse.Release: | |
| 272 | - | rt.copyMu.Lock() | |
| 273 | - | rt.normalMousePressValid = false | |
| 274 | - | rt.copyMu.Unlock() | |
| 275 | - | } | |
| 276 | 172 | case mouse.WheelUp: | |
| 277 | 173 | if altScreen { | |
| 278 | 174 | d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A")) | |
M internal/usecase/daemon/input_test.go +521 -40
| @@ -15,8 +15,10 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/keys" | |
| 19 | 20 | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 20 | 22 | "github.com/bnema/vev/internal/usecase/picker" | |
| 21 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 22 | 24 | ) | |
| @@ -844,7 +846,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||
| 844 | 846 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 845 | 847 | data := mustOutputData(t, sends) | |
| 846 | 848 | require.NotNil(t, ac.overlays.copyMode) | |
| 847 | - | require.Equal(t, 19, ac.overlays.copyMode.Cursor) | |
| 849 | + | require.Equal(t, 19, ac.overlays.copyMode.Cursor().Row) | |
| 848 | 850 | require.Contains(t, string(data), "[SCROLL]") | |
| 849 | 851 | ||
| 850 | 852 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| @@ -945,17 +947,62 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 945 | 947 | } | |
| 946 | 948 | ||
| 947 | 949 | sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | |
| 948 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 950 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 949 | 951 | require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode") | |
| 950 | - | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 952 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;4M")) | |
| 951 | 953 | mustOutputData(t, sends) | |
| 952 | 954 | require.NotNil(t, ac.overlays.copyMode) | |
| 953 | - | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 954 | - | require.True(t, ok) | |
| 955 | + | selection := ac.overlays.copyMode.Selection() | |
| 956 | + | require.True(t, selection.Enabled) | |
| 957 | + | lo, hi := selection.Anchor.Row, selection.Active.Row | |
| 958 | + | if lo > hi { | |
| 959 | + | lo, hi = hi, lo | |
| 960 | + | } | |
| 955 | 961 | require.Equal(t, 0, lo) | |
| 956 | 962 | require.Equal(t, 2, hi) | |
| 957 | 963 | } | |
| 958 | 964 | ||
| 965 | + | func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) { | |
| 966 | + | p, release := newBlockingPTY(t) | |
| 967 | + | defer release() | |
| 968 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 969 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 970 | + | d.enterCopyMode(sess, ac) | |
| 971 | + | mustOutputData(t, sends) | |
| 972 | + | ||
| 973 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m")) | |
| 974 | + | d.handleInput(sess, ac, []byte("y")) | |
| 975 | + | ||
| 976 | + | for range 2 { // press repaint, then copy-mode exit repaint | |
| 977 | + | require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy") | |
| 978 | + | } | |
| 979 | + | require.False(t, ac.overlays.copyActive()) | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) { | |
| 983 | + | p, _ := newBlockingPTY(t) | |
| 984 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 985 | + | copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha")) | |
| 986 | + | d.enterCopyMode(sess, ac) | |
| 987 | + | mustOutputData(t, sends) | |
| 988 | + | ||
| 989 | + | // SGR is one based: Cy2 is the first body row. Dragging from column 3 | |
| 990 | + | // back to column 1 is the same stream as the forward endpoints 1..3. | |
| 991 | + | d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M")) | |
| 992 | + | mustOutputData(t, sends) | |
| 993 | + | d.handleInput(sess, ac, []byte("\x1b[<0;2;2m")) | |
| 994 | + | d.handleInput(sess, ac, []byte("y")) | |
| 995 | + | var msg ports.Output | |
| 996 | + | require.Eventually(t, func() bool { | |
| 997 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 998 | + | var err error | |
| 999 | + | msg, err = ports.UnmarshalOutput(frame.Payload) | |
| 1000 | + | return err == nil && string(msg.Data) == string(scopy.OSC52("lph")[0]) | |
| 1001 | + | }, 2*time.Second, 5*time.Millisecond) | |
| 1002 | + | require.Equal(t, scopy.OSC52("lph")[0], msg.Data) | |
| 1003 | + | require.Nil(t, ac.overlays.copyPointer.pane) | |
| 1004 | + | } | |
| 1005 | + | ||
| 959 | 1006 | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |
| 960 | 1007 | p, _ := newBlockingPTY(t) | |
| 961 | 1008 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -965,11 +1012,15 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 965 | 1012 | ||
| 966 | 1013 | d.enterCopyMode(sess, ac) | |
| 967 | 1014 | mustOutputData(t, sends) | |
| 968 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 1015 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 969 | 1016 | mustOutputData(t, sends) | |
| 970 | 1017 | require.NotNil(t, ac.overlays.copyMode) | |
| 971 | - | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 972 | - | require.True(t, ok) | |
| 1018 | + | selection := ac.overlays.copyMode.Selection() | |
| 1019 | + | require.True(t, selection.Enabled) | |
| 1020 | + | lo, hi := selection.Anchor.Row, selection.Active.Row | |
| 1021 | + | if lo > hi { | |
| 1022 | + | lo, hi = hi, lo | |
| 1023 | + | } | |
| 973 | 1024 | require.Equal(t, 0, lo) | |
| 974 | 1025 | require.Equal(t, 1, hi) | |
| 975 | 1026 | ||
| @@ -981,7 +1032,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 981 | 1032 | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |
| 982 | 1033 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 983 | 1034 | require.NoError(t, err) | |
| 984 | - | require.Equal(t, "alpha\nbravo", string(decoded)) | |
| 1035 | + | require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded)) | |
| 985 | 1036 | require.Nil(t, ac.overlays.copyMode) | |
| 986 | 1037 | ||
| 987 | 1038 | exitPaint := string(mustOutputData(t, sends)) | |
| @@ -1001,16 +1052,16 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 1001 | 1052 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1002 | 1053 | ||
| 1003 | 1054 | // Press on a content row establishes a (soon to be stale) anchor. | |
| 1004 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 1055 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1005 | 1056 | require.Nil(t, ac.overlays.copyMode) | |
| 1006 | 1057 | ||
| 1007 | 1058 | // Release lands on the status row: must still clear the press state, | |
| 1008 | 1059 | // regardless of the row it landed on. | |
| 1009 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24m")) | |
| 1060 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m")) | |
| 1010 | 1061 | ||
| 1011 | 1062 | // A brand new button hold starts with a Press on the status row: must | |
| 1012 | 1063 | // clear (not silently preserve) any prior press state. | |
| 1013 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 1064 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25M")) | |
| 1014 | 1065 | ||
| 1015 | 1066 | // Motion onto a content row must not resurrect a stale anchor. | |
| 1016 | 1067 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| @@ -1024,6 +1075,28 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 1024 | 1075 | // a Press on the status row left the previous drag state in place and a | |
| 1025 | 1076 | // following Motion on a content row silently extended the old selection | |
| 1026 | 1077 | // instead of being a no-op. | |
| 1078 | + | func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) { | |
| 1079 | + | p, release := newBlockingPTY(t) | |
| 1080 | + | defer release() | |
| 1081 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1082 | + | d.enterCopyMode(sess, ac) | |
| 1083 | + | mustOutputData(t, sends) | |
| 1084 | + | ||
| 1085 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1086 | + | ac.overlays.copyMu.Lock() | |
| 1087 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1088 | + | ac.overlays.copyMu.Unlock() | |
| 1089 | + | ||
| 1090 | + | // Wire row 25 is below the focused pane content, so the release is not a | |
| 1091 | + | // valid endpoint and must not leave a drag that later motion can revive. | |
| 1092 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M")) | |
| 1093 | + | ac.overlays.copyMu.Lock() | |
| 1094 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1095 | + | selection := ac.overlays.copyMode.Selection() | |
| 1096 | + | require.False(t, selection.Enabled) | |
| 1097 | + | ac.overlays.copyMu.Unlock() | |
| 1098 | + | } | |
| 1099 | + | ||
| 1027 | 1100 | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |
| 1028 | 1101 | p, _ := newBlockingPTY(t) | |
| 1029 | 1102 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -1036,61 +1109,66 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 1036 | 1109 | mustOutputData(t, sends) | |
| 1037 | 1110 | ||
| 1038 | 1111 | // Full drag: press row0, motion to row1 -> selection [0,1]. | |
| 1039 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 1112 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M")) | |
| 1040 | 1113 | mustOutputData(t, sends) | |
| 1041 | 1114 | require.NotNil(t, ac.overlays.copyMode) | |
| 1042 | - | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 1043 | - | require.True(t, ok) | |
| 1115 | + | selection := ac.overlays.copyMode.Selection() | |
| 1116 | + | require.True(t, selection.Enabled) | |
| 1117 | + | lo, hi := selection.Anchor.Row, selection.Active.Row | |
| 1118 | + | if lo > hi { | |
| 1119 | + | lo, hi = hi, lo | |
| 1120 | + | } | |
| 1044 | 1121 | require.Equal(t, 0, lo) | |
| 1045 | 1122 | require.Equal(t, 1, hi) | |
| 1046 | 1123 | ||
| 1047 | 1124 | // Release, then a new button hold starting with Press on the status row. | |
| 1048 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |
| 1049 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 1125 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;3m")) | |
| 1126 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;25M")) | |
| 1050 | 1127 | ||
| 1051 | 1128 | // Motion onto a content row must be a no-op: the status-row press must | |
| 1052 | 1129 | // have cleared copyPressRowValid/copyDragging. | |
| 1053 | 1130 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 1054 | 1131 | ||
| 1055 | 1132 | require.NotNil(t, ac.overlays.copyMode) | |
| 1056 | - | lo, hi, ok = ac.overlays.copyMode.SelectedBounds() | |
| 1057 | - | require.True(t, ok) | |
| 1133 | + | selection = ac.overlays.copyMode.Selection() | |
| 1134 | + | require.True(t, selection.Enabled) | |
| 1135 | + | lo, hi = selection.Anchor.Row, selection.Active.Row | |
| 1136 | + | if lo > hi { | |
| 1137 | + | lo, hi = hi, lo | |
| 1138 | + | } | |
| 1058 | 1139 | require.Equal(t, 0, lo, "anchor must not have moved") | |
| 1059 | 1140 | require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op") | |
| 1060 | 1141 | } | |
| 1061 | 1142 | ||
| 1062 | - | // TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset covers a | |
| 1063 | - | // regression where the first drag Motion extended the selection using the | |
| 1064 | - | // scrollback length captured at Press time instead of the current | |
| 1065 | - | // scrollback length, so if lines were evicted into scrollback between the | |
| 1066 | - | // Press and the first Motion, the extend target landed on the wrong row. | |
| 1067 | - | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |
| 1143 | + | // TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction verifies | |
| 1144 | + | // that output eviction after Press does not change endpoint mapping: the | |
| 1145 | + | // immutable press-owned Document keeps pointer rows 0→1 mapped to rows 0→1. | |
| 1146 | + | func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testing.T) { | |
| 1068 | 1147 | p, _ := newBlockingPTY(t) | |
| 1069 | 1148 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1070 | 1149 | installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50}) | |
| 1071 | 1150 | require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 1072 | 1151 | ||
| 1073 | - | // Press on a content row while scrollback is empty: the anchor is | |
| 1074 | - | // content-stable at pressTop(0)+pressRow(0) = row 0. | |
| 1075 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 1152 | + | // Press anchors document row 0 before output eviction. | |
| 1153 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1076 | 1154 | ||
| 1077 | - | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 1078 | - | // first Motion (e.g. the child kept producing output). | |
| 1155 | + | // Output evicts five lines after Press; the Document remains immutable. | |
| 1079 | 1156 | for range 5 { | |
| 1080 | 1157 | sess.tabs[0].focusedPane().history.Append(testRow("evicted")) | |
| 1081 | 1158 | } | |
| 1082 | 1159 | ||
| 1083 | - | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | |
| 1084 | - | // The extend target must track the pointer's current content row: | |
| 1085 | - | // current scrollback length (5) + ev.Row (2) = 7 -- not the stale | |
| 1086 | - | // pressTop(0)+ev.Row(2) = 2. | |
| 1160 | + | // Motion maps the next endpoint through the press-owned Document. | |
| 1087 | 1161 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 1088 | 1162 | ||
| 1089 | 1163 | require.NotNil(t, ac.overlays.copyMode) | |
| 1090 | - | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 1091 | - | require.True(t, ok) | |
| 1164 | + | selection := ac.overlays.copyMode.Selection() | |
| 1165 | + | require.True(t, selection.Enabled) | |
| 1166 | + | lo, hi := selection.Anchor.Row, selection.Active.Row | |
| 1167 | + | if lo > hi { | |
| 1168 | + | lo, hi = hi, lo | |
| 1169 | + | } | |
| 1092 | 1170 | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | |
| 1093 | - | require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset") | |
| 1171 | + | require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable") | |
| 1094 | 1172 | } | |
| 1095 | 1173 | ||
| 1096 | 1174 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | |
| @@ -1135,6 +1213,137 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing | |||
| 1135 | 1213 | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 1136 | 1214 | } | |
| 1137 | 1215 | ||
| 1216 | + | func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |
| 1217 | + | for _, tc := range []struct { | |
| 1218 | + | name string | |
| 1219 | + | setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte | |
| 1220 | + | reject []byte | |
| 1221 | + | }{ | |
| 1222 | + | { | |
| 1223 | + | name: "split divider", | |
| 1224 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |
| 1225 | + | tb := sess.activeTab() | |
| 1226 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1227 | + | tb.mu.Lock() | |
| 1228 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1229 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1230 | + | tb.panes["pane-2"] = p2 | |
| 1231 | + | tb.mu.Unlock() | |
| 1232 | + | return []byte("\x1b[<0;21;2m") | |
| 1233 | + | }, | |
| 1234 | + | }, | |
| 1235 | + | { | |
| 1236 | + | name: "split title bar", | |
| 1237 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte { | |
| 1238 | + | tb := sess.activeTab() | |
| 1239 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1240 | + | tb.mu.Lock() | |
| 1241 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1242 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1243 | + | tb.panes["pane-2"] = p2 | |
| 1244 | + | tb.mu.Unlock() | |
| 1245 | + | return []byte("\x1b[<0;1;1m") | |
| 1246 | + | }, | |
| 1247 | + | }, | |
| 1248 | + | { | |
| 1249 | + | name: "floating exterior", | |
| 1250 | + | setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte { | |
| 1251 | + | tb := sess.activeTab() | |
| 1252 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 1253 | + | installTestFloating(tb, floating, true) | |
| 1254 | + | tb.mu.Lock() | |
| 1255 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 1256 | + | tb.mu.Unlock() | |
| 1257 | + | require.True(t, visible) | |
| 1258 | + | *press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2) | |
| 1259 | + | return []byte("\x1b[<0;1;1m") | |
| 1260 | + | }, | |
| 1261 | + | }, | |
| 1262 | + | } { | |
| 1263 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1264 | + | p, release := newBlockingPTY(t) | |
| 1265 | + | defer release() | |
| 1266 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1267 | + | press := []byte("\x1b[<0;1;2M") | |
| 1268 | + | reject := tc.setup(t, d, sess, ac, &press) | |
| 1269 | + | ||
| 1270 | + | // A content press is fresh state. The rejected release must clear it | |
| 1271 | + | // before a following motion has an opportunity to publish copy mode. | |
| 1272 | + | d.handleInput(sess, ac, press) | |
| 1273 | + | ac.overlays.copyMu.Lock() | |
| 1274 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1275 | + | ac.overlays.copyMu.Unlock() | |
| 1276 | + | ||
| 1277 | + | d.handleInput(sess, ac, reject) | |
| 1278 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 1279 | + | ||
| 1280 | + | ac.overlays.copyMu.Lock() | |
| 1281 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1282 | + | require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish") | |
| 1283 | + | ac.overlays.copyMu.Unlock() | |
| 1284 | + | }) | |
| 1285 | + | } | |
| 1286 | + | } | |
| 1287 | + | ||
| 1288 | + | func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) { | |
| 1289 | + | p, release := newBlockingPTY(t) | |
| 1290 | + | defer release() | |
| 1291 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1292 | + | ||
| 1293 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1294 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press | |
| 1295 | + | ac.overlays.copyMu.Lock() | |
| 1296 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1297 | + | ac.overlays.copyMu.Unlock() | |
| 1298 | + | ||
| 1299 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1300 | + | ac.setSession(nil) | |
| 1301 | + | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |
| 1302 | + | ac.overlays.copyMu.Lock() | |
| 1303 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1304 | + | ac.overlays.copyMu.Unlock() | |
| 1305 | + | } | |
| 1306 | + | ||
| 1307 | + | func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |
| 1308 | + | p, release := newBlockingPTY(t) | |
| 1309 | + | defer release() | |
| 1310 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1311 | + | tb := sess.activeTab() | |
| 1312 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 8}) | |
| 1313 | + | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | |
| 1314 | + | tb.mu.Lock() | |
| 1315 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1316 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 1317 | + | layout.NewLeaf("pane-1"), | |
| 1318 | + | {Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}, Expanded: "pane-2"}, | |
| 1319 | + | }} | |
| 1320 | + | tb.tree.Focus = "pane-2" | |
| 1321 | + | tb.panes["pane-2"] = p2 | |
| 1322 | + | tb.panes["pane-3"] = p3 | |
| 1323 | + | tb.mu.Unlock() | |
| 1324 | + | ||
| 1325 | + | // A content press gives pane-2 a fresh candidate. Pressing that already | |
| 1326 | + | // focused pane's title bar must make the following motion a no-op rather | |
| 1327 | + | // than reusing this pointer to publish copy mode. | |
| 1328 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | |
| 1329 | + | ac.overlays.copyMu.Lock() | |
| 1330 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1331 | + | epoch := ac.overlays.copyPointerEpoch | |
| 1332 | + | ac.overlays.copyMu.Unlock() | |
| 1333 | + | ||
| 1334 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1335 | + | ac.overlays.copyMu.Lock() | |
| 1336 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1337 | + | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | |
| 1338 | + | ac.overlays.copyMu.Unlock() | |
| 1339 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | |
| 1340 | + | ||
| 1341 | + | d.handleInput(sess, ac, []byte("\x1b[<32;22;4M")) | |
| 1342 | + | ac.overlays.copyMu.Lock() | |
| 1343 | + | require.Nil(t, ac.overlays.copyMode, "motion after title-bar press must not publish stale pointer state") | |
| 1344 | + | ac.overlays.copyMu.Unlock() | |
| 1345 | + | } | |
| 1346 | + | ||
| 1138 | 1347 | func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) { | |
| 1139 | 1348 | cases := []struct { | |
| 1140 | 1349 | name string | |
| @@ -1199,11 +1408,15 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) { | |||
| 1199 | 1408 | d.enterCopyMode(sess, ac) | |
| 1200 | 1409 | mustOutputData(t, sends) | |
| 1201 | 1410 | ||
| 1202 | - | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M")) | |
| 1411 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M")) | |
| 1203 | 1412 | mustOutputData(t, sends) | |
| 1204 | 1413 | ||
| 1205 | - | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |
| 1206 | - | require.True(t, ok) | |
| 1414 | + | selection := ac.overlays.copyMode.Selection() | |
| 1415 | + | require.True(t, selection.Enabled) | |
| 1416 | + | lo, hi := selection.Anchor.Row, selection.Active.Row | |
| 1417 | + | if lo > hi { | |
| 1418 | + | lo, hi = hi, lo | |
| 1419 | + | } | |
| 1207 | 1420 | require.Equal(t, 0, lo) | |
| 1208 | 1421 | require.Equal(t, 9, hi) | |
| 1209 | 1422 | } | |
| @@ -1252,3 +1465,271 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||
| 1252 | 1465 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 1253 | 1466 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |
| 1254 | 1467 | } | |
| 1468 | + | ||
| 1469 | + | func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) { | |
| 1470 | + | p1, release := newBlockingPTY(t) | |
| 1471 | + | defer release() | |
| 1472 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1473 | + | tb := sess.activeTab() | |
| 1474 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1475 | + | tb.mu.Lock() | |
| 1476 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1477 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1478 | + | tb.panes["pane-2"] = p2 | |
| 1479 | + | tb.mu.Unlock() | |
| 1480 | + | ||
| 1481 | + | d.enterCopyMode(sess, ac) | |
| 1482 | + | require.True(t, ac.overlays.copyActive()) | |
| 1483 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1484 | + | ac.overlays.copyMu.Lock() | |
| 1485 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1486 | + | require.True(t, ac.overlays.copyClick.valid) | |
| 1487 | + | ac.overlays.copyMu.Unlock() | |
| 1488 | + | ||
| 1489 | + | // A press in the right split must focus it, then leave the old pane's copy | |
| 1490 | + | // snapshot behind. The following press is therefore a fresh p2 candidate. | |
| 1491 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1492 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 1493 | + | require.False(t, ac.overlays.copyActive()) | |
| 1494 | + | ac.overlays.copyMu.Lock() | |
| 1495 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1496 | + | require.False(t, ac.overlays.copyClick.valid) | |
| 1497 | + | ac.overlays.copyMu.Unlock() | |
| 1498 | + | ||
| 1499 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1500 | + | ac.overlays.copyMu.Lock() | |
| 1501 | + | pointer := ac.overlays.copyPointer | |
| 1502 | + | ac.overlays.copyMu.Unlock() | |
| 1503 | + | require.True(t, pointer.valid) | |
| 1504 | + | require.Same(t, p2, pointer.pane) | |
| 1505 | + | } | |
| 1506 | + | ||
| 1507 | + | func TestCopyModeReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) { | |
| 1508 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |
| 1509 | + | ||
| 1510 | + | // Motion starts an active drag; Release then arrives farther away without | |
| 1511 | + | // another Motion and must still extend its final endpoint. | |
| 1512 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M") | |
| 1513 | + | copyMouseInput(d, sess, ac, "\x1b[<0;8;2m") | |
| 1514 | + | ||
| 1515 | + | ac.overlays.copyMu.Lock() | |
| 1516 | + | selection := ac.overlays.copyMode.Selection() | |
| 1517 | + | pointerValid := ac.overlays.copyPointer.valid | |
| 1518 | + | ac.overlays.copyMu.Unlock() | |
| 1519 | + | require.True(t, selection.Enabled) | |
| 1520 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |
| 1521 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active) | |
| 1522 | + | require.False(t, pointerValid) | |
| 1523 | + | } | |
| 1524 | + | ||
| 1525 | + | func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) { | |
| 1526 | + | for _, tc := range []struct { | |
| 1527 | + | name string | |
| 1528 | + | release string | |
| 1529 | + | wantCol int | |
| 1530 | + | }{ | |
| 1531 | + | {name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7}, | |
| 1532 | + | {name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79}, | |
| 1533 | + | } { | |
| 1534 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1535 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |
| 1536 | + | ||
| 1537 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M") | |
| 1538 | + | copyMouseInput(d, sess, ac, "/") | |
| 1539 | + | require.True(t, ac.overlays.copySearchActive()) | |
| 1540 | + | ||
| 1541 | + | // Search owns ordinary mouse input, but the release still completes | |
| 1542 | + | // the active drag against its press-owned geometry. | |
| 1543 | + | copyMouseInput(d, sess, ac, tc.release) | |
| 1544 | + | copyMouseInput(d, sess, ac, "\x03") | |
| 1545 | + | ||
| 1546 | + | ac.overlays.copyMu.Lock() | |
| 1547 | + | selection := ac.overlays.copyMode.Selection() | |
| 1548 | + | pointerValid := ac.overlays.copyPointer.valid | |
| 1549 | + | ac.overlays.copyMu.Unlock() | |
| 1550 | + | require.True(t, selection.Enabled) | |
| 1551 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |
| 1552 | + | require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active) | |
| 1553 | + | require.False(t, pointerValid) | |
| 1554 | + | require.False(t, ac.overlays.copySearchActive()) | |
| 1555 | + | }) | |
| 1556 | + | } | |
| 1557 | + | } | |
| 1558 | + | ||
| 1559 | + | // TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race | |
| 1560 | + | // deterministic. Each path captures a copy-input snapshot, yields while that | |
| 1561 | + | // snapshot is outside copyMu, then starts a new pointer. The old event must | |
| 1562 | + | // never invalidate the new interaction. | |
| 1563 | + | func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) { | |
| 1564 | + | for _, tc := range []struct { | |
| 1565 | + | name string | |
| 1566 | + | prepare func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event | |
| 1567 | + | }{ | |
| 1568 | + | { | |
| 1569 | + | name: "press-owned release rejects mapping", | |
| 1570 | + | prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event { | |
| 1571 | + | t.Helper() | |
| 1572 | + | p := sess.activeTab().focusedPane() | |
| 1573 | + | tb := sess.activeTab() | |
| 1574 | + | tb.mu.Lock() | |
| 1575 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2) | |
| 1576 | + | tb.mu.Unlock() | |
| 1577 | + | require.True(t, ok) | |
| 1578 | + | ac.overlays.copyMu.Lock() | |
| 1579 | + | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: ac.overlays.copyDocument, geometry: geometry}) | |
| 1580 | + | ac.overlays.copyMu.Unlock() | |
| 1581 | + | return mouse.Event{Button: mouse.Left, Type: mouse.Release, Col: geometry.content.X - 1, Row: geometry.content.Y} | |
| 1582 | + | }, | |
| 1583 | + | }, | |
| 1584 | + | { | |
| 1585 | + | name: "current geometry disappears", | |
| 1586 | + | prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event { | |
| 1587 | + | t.Helper() | |
| 1588 | + | tb := sess.activeTab() | |
| 1589 | + | tb.mu.Lock() | |
| 1590 | + | tb.tree = nil | |
| 1591 | + | tb.mu.Unlock() | |
| 1592 | + | return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 2} | |
| 1593 | + | }, | |
| 1594 | + | }, | |
| 1595 | + | { | |
| 1596 | + | name: "fresh press rejects document mapping", | |
| 1597 | + | prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event { | |
| 1598 | + | t.Helper() | |
| 1599 | + | tb := sess.activeTab() | |
| 1600 | + | tb.mu.Lock() | |
| 1601 | + | tb.size.Rows = 40 // valid layout coordinate, outside the old document. | |
| 1602 | + | tb.mu.Unlock() | |
| 1603 | + | return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 30} | |
| 1604 | + | }, | |
| 1605 | + | }, | |
| 1606 | + | } { | |
| 1607 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1608 | + | p, release := newBlockingPTY(t) | |
| 1609 | + | defer release() | |
| 1610 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1611 | + | d.enterCopyMode(sess, ac) | |
| 1612 | + | mustOutputData(t, sends) | |
| 1613 | + | ev := tc.prepare(t, d, sess, ac) | |
| 1614 | + | ||
| 1615 | + | var want copyPointerState | |
| 1616 | + | d.beforeCopyMouseMap = func() { | |
| 1617 | + | ac.overlays.copyMu.Lock() | |
| 1618 | + | ac.overlays.beginCopyPointerLocked(copyPointerState{ | |
| 1619 | + | pane: ac.overlays.copyPane, | |
| 1620 | + | document: ac.overlays.copyDocument, | |
| 1621 | + | press: scopy.Pos{Row: 7, Col: 7}, | |
| 1622 | + | }) | |
| 1623 | + | want = ac.overlays.copyPointer | |
| 1624 | + | ac.overlays.copyMu.Unlock() | |
| 1625 | + | } | |
| 1626 | + | defer func() { d.beforeCopyMouseMap = nil }() | |
| 1627 | + | ||
| 1628 | + | d.handleActiveCopyMouse(sess, ac, sess.activeTab(), ev) | |
| 1629 | + | ||
| 1630 | + | ac.overlays.copyMu.Lock() | |
| 1631 | + | defer ac.overlays.copyMu.Unlock() | |
| 1632 | + | require.True(t, want.valid, "test seam must start the newer pointer") | |
| 1633 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1634 | + | require.Equal(t, want.epoch, ac.overlays.copyPointerEpoch) | |
| 1635 | + | require.Equal(t, want.epoch, ac.overlays.copyPointer.epoch) | |
| 1636 | + | require.Same(t, want.pane, ac.overlays.copyPointer.pane) | |
| 1637 | + | require.Same(t, want.document, ac.overlays.copyPointer.document) | |
| 1638 | + | require.Equal(t, want.press, ac.overlays.copyPointer.press) | |
| 1639 | + | }) | |
| 1640 | + | } | |
| 1641 | + | } | |
| 1642 | + | ||
| 1643 | + | func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) { | |
| 1644 | + | p, release := newBlockingPTY(t) | |
| 1645 | + | defer release() | |
| 1646 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1647 | + | pane := sess.activeTab().focusedPane() | |
| 1648 | + | for range 8 { | |
| 1649 | + | pane.history.Append(testRow("history")) | |
| 1650 | + | } | |
| 1651 | + | d.enterCopyMode(sess, ac) | |
| 1652 | + | mustOutputData(t, sends) | |
| 1653 | + | ||
| 1654 | + | ac.overlays.copyMu.Lock() | |
| 1655 | + | before := ac.overlays.copyMode.Cursor() | |
| 1656 | + | ac.overlays.copyMu.Unlock() | |
| 1657 | + | d.beforeCopyMouseMap = func() { | |
| 1658 | + | ac.overlays.copyMu.Lock() | |
| 1659 | + | ac.overlays.copyMode.ViewportTop-- | |
| 1660 | + | ac.overlays.copyMu.Unlock() | |
| 1661 | + | } | |
| 1662 | + | defer func() { d.beforeCopyMouseMap = nil }() | |
| 1663 | + | ||
| 1664 | + | // The seam changes ViewportTop after the map snapshot but before copyMouse | |
| 1665 | + | // revalidates it. The stale mapped row must not move the selection cursor. | |
| 1666 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2M")) | |
| 1667 | + | ac.overlays.copyMu.Lock() | |
| 1668 | + | require.Equal(t, before, ac.overlays.copyMode.Cursor()) | |
| 1669 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 1670 | + | ac.overlays.copyMu.Unlock() | |
| 1671 | + | } | |
| 1672 | + | ||
| 1673 | + | func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) { | |
| 1674 | + | for _, tc := range []struct { | |
| 1675 | + | name string | |
| 1676 | + | setup func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) | |
| 1677 | + | }{ | |
| 1678 | + | { | |
| 1679 | + | name: "mono", | |
| 1680 | + | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |
| 1681 | + | return sess.activeTab().focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1} | |
| 1682 | + | }, | |
| 1683 | + | }, | |
| 1684 | + | { | |
| 1685 | + | name: "split", | |
| 1686 | + | setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) { | |
| 1687 | + | tb := sess.activeTab() | |
| 1688 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 1689 | + | tb.mu.Lock() | |
| 1690 | + | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| 1691 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1692 | + | tb.panes["pane-2"] = p2 | |
| 1693 | + | tb.mu.Unlock() | |
| 1694 | + | return p2, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 22, Row: 3} | |
| 1695 | + | }, | |
| 1696 | + | }, | |
| 1697 | + | { | |
| 1698 | + | name: "floating", | |
| 1699 | + | setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) { | |
| 1700 | + | p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 1701 | + | installTestFloating(sess.activeTab(), p, true) | |
| 1702 | + | tb := sess.activeTab() | |
| 1703 | + | tb.mu.Lock() | |
| 1704 | + | _, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 1705 | + | tb.mu.Unlock() | |
| 1706 | + | require.True(t, visible) | |
| 1707 | + | return p, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: geometry.Inner.X + 1, Row: geometry.Inner.Y + 2} | |
| 1708 | + | }, | |
| 1709 | + | }, | |
| 1710 | + | } { | |
| 1711 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1712 | + | p, release := newBlockingPTY(t) | |
| 1713 | + | defer release() | |
| 1714 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1715 | + | wantPane, ev := tc.setup(t, d, sess) | |
| 1716 | + | tb := sess.activeTab() | |
| 1717 | + | tb.mu.Lock() | |
| 1718 | + | wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 1719 | + | tb.mu.Unlock() | |
| 1720 | + | require.True(t, ok) | |
| 1721 | + | require.Same(t, wantPane, wantGeometry.pane) | |
| 1722 | + | ||
| 1723 | + | d.handleMouse(ac, ev) | |
| 1724 | + | ac.overlays.copyMu.Lock() | |
| 1725 | + | pointer := ac.overlays.copyPointer | |
| 1726 | + | ac.overlays.copyMu.Unlock() | |
| 1727 | + | require.True(t, pointer.valid) | |
| 1728 | + | require.Same(t, wantPane, pointer.pane) | |
| 1729 | + | require.Equal(t, wantGeometry, pointer.geometry) | |
| 1730 | + | mapped, ok := mapCopyMouse(ev, wantGeometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, false) | |
| 1731 | + | require.True(t, ok) | |
| 1732 | + | require.Equal(t, mapped.pos, pointer.press) | |
| 1733 | + | }) | |
| 1734 | + | } | |
| 1735 | + | } | |
M internal/usecase/daemon/mouse_panes.go +0 -3
| @@ -144,8 +144,5 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||
| 144 | 144 | } | |
| 145 | 145 | rt.copyMu.Lock() | |
| 146 | 146 | rt.clearCopyModeLocked() | |
| 147 | - | rt.copyPressRowValid = false | |
| 148 | - | rt.copyDragging = false | |
| 149 | - | rt.normalMousePressValid = false | |
| 150 | 147 | rt.copyMu.Unlock() | |
| 151 | 148 | } | |
M internal/usecase/daemon/overlay_runtime.go +75 -23
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "sync" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/palette" | |
| @@ -33,22 +34,38 @@ type overlayRuntime struct { | |||
| 33 | 34 | promptSubmit func(string) error | |
| 34 | 35 | promptPending []byte | |
| 35 | 36 | ||
| 36 | - | copyMu sync.Mutex | |
| 37 | - | copyMode *scopy.Mode | |
| 38 | - | copyCandidate *scopy.Mode | |
| 39 | - | copySnapshot *scopy.Snapshot | |
| 40 | - | copyPane *pane | |
| 41 | - | copyPending []byte | |
| 42 | - | copyESC pendingByteTimer | |
| 43 | - | copySearch *visualsearch.Model | |
| 44 | - | copySearchPending []byte | |
| 45 | - | copyFeedback string | |
| 46 | - | copyPressRow int | |
| 47 | - | copyPressRowValid bool | |
| 48 | - | copyDragging bool | |
| 49 | - | normalMousePressRow int | |
| 50 | - | normalMousePressTop int | |
| 51 | - | normalMousePressValid bool | |
| 37 | + | copyMu sync.Mutex | |
| 38 | + | copyMode *scopy.Mode | |
| 39 | + | copyCandidate *scopy.Mode | |
| 40 | + | copyDocument *scopy.Document | |
| 41 | + | copyPane *pane | |
| 42 | + | copyPending []byte | |
| 43 | + | copyESC pendingByteTimer | |
| 44 | + | copySearch *visualsearch.Model | |
| 45 | + | copySearchPending []byte | |
| 46 | + | copyFeedback string | |
| 47 | + | copyPointer copyPointerState | |
| 48 | + | copyClick copyClickCandidate | |
| 49 | + | copyPointerEpoch uint64 | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type copyPointerState struct { | |
| 53 | + | valid bool | |
| 54 | + | epoch uint64 | |
| 55 | + | pane *pane | |
| 56 | + | document *scopy.Document | |
| 57 | + | geometry copyMouseGeometry | |
| 58 | + | press scopy.Pos | |
| 59 | + | dragging bool | |
| 60 | + | wordDrag bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type copyClickCandidate struct { | |
| 64 | + | valid bool | |
| 65 | + | pane *pane | |
| 66 | + | pos scopy.Pos | |
| 67 | + | at time.Time | |
| 68 | + | dragged bool | |
| 52 | 69 | } | |
| 53 | 70 | ||
| 54 | 71 | func newOverlayRuntime(ac *attachedClient) *overlayRuntime { | |
| @@ -107,13 +124,47 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||
| 107 | 124 | return rt.copySearch != nil | |
| 108 | 125 | } | |
| 109 | 126 | ||
| 127 | + | func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |
| 128 | + | rt.copyPointerEpoch++ | |
| 129 | + | pointer.epoch = rt.copyPointerEpoch | |
| 130 | + | pointer.valid = true | |
| 131 | + | rt.copyPointer = pointer | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | |
| 135 | + | rt.copyPointerEpoch++ | |
| 136 | + | rt.copyPointer = copyPointerState{} | |
| 137 | + | if clearClick { | |
| 138 | + | rt.copyClick = copyClickCandidate{} | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // clearCopyPointerForTransferLocked leaves the epoch unchanged so an input or | |
| 143 | + | // teardown occurring while publication revalidates can invalidate the transfer. | |
| 144 | + | func (rt *overlayRuntime) clearCopyPointerForTransferLocked() { | |
| 145 | + | rt.copyPointer = copyPointerState{} | |
| 146 | + | rt.copyClick = copyClickCandidate{} | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (rt *overlayRuntime) discardCopyCandidateLocked(candidate *scopy.Mode) { | |
| 150 | + | if rt.copyCandidate != candidate { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | rt.copyCandidate = nil | |
| 154 | + | rt.copyDocument = nil | |
| 155 | + | rt.copyPane = nil | |
| 156 | + | rt.copySearch = nil | |
| 157 | + | rt.copySearchPending = nil | |
| 158 | + | } | |
| 159 | + | ||
| 110 | 160 | func (rt *overlayRuntime) clearCopyModeLocked() { | |
| 111 | 161 | rt.copyMode = nil | |
| 112 | 162 | rt.copyCandidate = nil | |
| 113 | - | rt.copySnapshot = nil | |
| 163 | + | rt.copyDocument = nil | |
| 114 | 164 | rt.copyPane = nil | |
| 115 | 165 | rt.copySearch = nil | |
| 116 | 166 | rt.copySearchPending = nil | |
| 167 | + | rt.invalidateCopyPointerLocked(true) | |
| 117 | 168 | } | |
| 118 | 169 | ||
| 119 | 170 | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |
| @@ -122,11 +173,14 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 122 | 173 | } | |
| 123 | 174 | rt.copyMu.Lock() | |
| 124 | 175 | defer rt.copyMu.Unlock() | |
| 125 | - | if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) { | |
| 126 | - | return false | |
| 176 | + | active := rt.copyPane == p && (rt.copyMode != nil || rt.copyCandidate != nil) | |
| 177 | + | prePublication := rt.copyPointer.pane == p || rt.copyClick.pane == p | |
| 178 | + | if active { | |
| 179 | + | rt.clearCopyModeLocked() | |
| 180 | + | } else if prePublication { | |
| 181 | + | rt.invalidateCopyPointerLocked(true) | |
| 127 | 182 | } | |
| 128 | - | rt.clearCopyModeLocked() | |
| 129 | - | return true | |
| 183 | + | return active || prePublication | |
| 130 | 184 | } | |
| 131 | 185 | ||
| 132 | 186 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |
| @@ -158,7 +212,6 @@ type overlayRenderSnapshot struct { | |||
| 158 | 212 | ||
| 159 | 213 | copyActive bool | |
| 160 | 214 | copyMode *scopy.Mode | |
| 161 | - | copySnapshot *scopy.Snapshot | |
| 162 | 215 | copyPane *pane | |
| 163 | 216 | copySearchModel *visualsearch.Model | |
| 164 | 217 | copyFeedback string | |
| @@ -195,7 +248,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 195 | 248 | rt.copyMu.Lock() | |
| 196 | 249 | snap.copyActive = rt.copyMode != nil | |
| 197 | 250 | snap.copyPane = rt.copyPane | |
| 198 | - | snap.copySnapshot = rt.copySnapshot | |
| 199 | 251 | if rt.copyMode != nil { | |
| 200 | 252 | copyModeValue := *rt.copyMode | |
| 201 | 253 | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | |
M internal/usecase/daemon/overlay_runtime_test.go +38 -12
| @@ -114,8 +114,8 @@ func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) { | |||
| 114 | 114 | ac := &attachedClient{} | |
| 115 | 115 | ac.initOverlays() | |
| 116 | 116 | snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2) | |
| 117 | - | mode := scopy.NewMode(snap) | |
| 118 | - | require.True(t, mode.Search(snap, "alpha")) | |
| 117 | + | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | + | require.True(t, mode.Search("alpha")) | |
| 119 | 119 | search := visualsearch.New(snap) | |
| 120 | 120 | for _, r := range "alpha" { | |
| 121 | 121 | search.Insert(r) | |
| @@ -145,46 +145,72 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) { | |||
| 145 | 145 | require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x"))) | |
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | - | func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.T) { | |
| 148 | + | func TestOverlayRuntimeSnapshotCarriesCopyModeDocumentAndClearReleasesIt(t *testing.T) { | |
| 149 | 149 | ac := &attachedClient{} | |
| 150 | 150 | ac.initOverlays() | |
| 151 | 151 | p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2}) | |
| 152 | - | document := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2) | |
| 152 | + | document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2), domain.DefaultWordSeparators) | |
| 153 | 153 | ||
| 154 | 154 | ac.overlays.copyMu.Lock() | |
| 155 | 155 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 156 | 156 | ac.overlays.copyPane = p | |
| 157 | - | ac.overlays.copySnapshot = &document | |
| 157 | + | ac.overlays.copyDocument = document | |
| 158 | 158 | ac.overlays.copyMu.Unlock() | |
| 159 | 159 | ||
| 160 | 160 | renderSnap := ac.overlays.SnapshotForRender() | |
| 161 | 161 | require.Same(t, p, renderSnap.copyPane) | |
| 162 | - | require.Same(t, &document, renderSnap.copySnapshot) | |
| 162 | + | require.NotNil(t, renderSnap.copyMode) | |
| 163 | + | require.Same(t, document, renderSnap.copyMode.Document()) | |
| 163 | 164 | renderSnap.Unlock() | |
| 164 | 165 | ||
| 165 | 166 | ac.overlays.copyMu.Lock() | |
| 166 | 167 | ac.overlays.clearCopyModeLocked() | |
| 167 | 168 | require.Nil(t, ac.overlays.copyPane) | |
| 168 | 169 | require.Nil(t, ac.overlays.copyMode) | |
| 169 | - | require.Nil(t, ac.overlays.copySnapshot) | |
| 170 | + | require.Nil(t, ac.overlays.copyDocument) | |
| 170 | 171 | ac.overlays.copyMu.Unlock() | |
| 171 | 172 | } | |
| 172 | 173 | ||
| 173 | - | func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | |
| 174 | + | func TestOverlayRuntimePointerEpochInvalidatesTransferAndPaneClose(t *testing.T) { | |
| 175 | + | ac := &attachedClient{} | |
| 176 | + | ac.initOverlays() | |
| 177 | + | p := newPane("pane", nil, domain.Size{Cols: 4, Rows: 1}) | |
| 178 | + | document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 1), domain.DefaultWordSeparators) | |
| 179 | + | ||
| 180 | + | ac.overlays.copyMu.Lock() | |
| 181 | + | ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: document, press: scopy.Pos{}}) | |
| 182 | + | staleEpoch := ac.overlays.copyPointer.epoch | |
| 183 | + | ac.overlays.clearCopyPointerForTransferLocked() | |
| 184 | + | ac.overlays.invalidateCopyPointerLocked(true) // release/close while publish revalidates | |
| 185 | + | require.NotEqual(t, staleEpoch, ac.overlays.copyPointerEpoch) | |
| 186 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 187 | + | ac.overlays.copyCandidate = scopy.NewMode(document) | |
| 188 | + | ac.overlays.copyDocument = document | |
| 189 | + | ac.overlays.copyPane = p | |
| 190 | + | ac.overlays.copyMu.Unlock() | |
| 191 | + | ||
| 192 | + | require.True(t, ac.overlays.clearCopyModeForPane(p)) | |
| 193 | + | ac.overlays.copyMu.Lock() | |
| 194 | + | require.False(t, ac.overlays.copyPointer.valid) | |
| 195 | + | ac.overlays.copyMu.Unlock() | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) { | |
| 174 | 199 | ac := &attachedClient{} | |
| 175 | 200 | ac.initOverlays() | |
| 176 | 201 | history := newTestHistory(1) | |
| 177 | 202 | history.Append(testRow("before")) | |
| 178 | - | document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1)) | |
| 203 | + | document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators) | |
| 179 | 204 | ||
| 180 | 205 | ac.overlays.copyMu.Lock() | |
| 181 | 206 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 182 | - | ac.overlays.copySnapshot = &document | |
| 207 | + | ac.overlays.copyDocument = document | |
| 183 | 208 | ac.overlays.copyMu.Unlock() | |
| 184 | 209 | ||
| 185 | 210 | history.Append(testRow("after")) | |
| 186 | 211 | renderSnap := ac.overlays.SnapshotForRender() | |
| 187 | 212 | defer renderSnap.Unlock() | |
| 188 | - | require.Same(t, &document, renderSnap.copySnapshot) | |
| 189 | - | require.Equal(t, "before", rowText(renderSnap.copySnapshot.Row(0))) | |
| 213 | + | require.NotNil(t, renderSnap.copyMode) | |
| 214 | + | require.Same(t, document, renderSnap.copyMode.Document()) | |
| 215 | + | require.Equal(t, "before", rowText(renderSnap.copyMode.Document().Snapshot().Row(0))) | |
| 190 | 216 | } | |
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
| @@ -869,7 +869,7 @@ func (f *performanceFixture) searchMatches() int { | |||
| 869 | 869 | func (f *performanceFixture) copyModeActive() bool { | |
| 870 | 870 | f.ac.overlays.copyMu.Lock() | |
| 871 | 871 | defer f.ac.overlays.copyMu.Unlock() | |
| 872 | - | return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000 | |
| 872 | + | return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000 | |
| 873 | 873 | } | |
| 874 | 874 | ||
| 875 | 875 | func (f *performanceFixture) resize() { | |
M internal/usecase/daemon/render_capture.go +0 -1
| @@ -84,7 +84,6 @@ type capturedFloatingRenderState struct { | |||
| 84 | 84 | type capturedOverlayRenderState struct { | |
| 85 | 85 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 86 | 86 | copyMode *scopy.Mode | |
| 87 | - | copySnapshot *scopy.Snapshot | |
| 88 | 87 | copyPaneID layout.PaneID | |
| 89 | 88 | copyFeedback string | |
| 90 | 89 | paletteGuidance string | |
M internal/usecase/daemon/render_pipeline.go +2 -2
| @@ -185,7 +185,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 185 | 185 | } | |
| 186 | 186 | o := &state.overlays | |
| 187 | 187 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |
| 188 | - | o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot | |
| 188 | + | o.copyMode = snap.copyMode | |
| 189 | 189 | if snap.copyPane != nil { | |
| 190 | 190 | o.copyPaneID = snap.copyPane.id | |
| 191 | 191 | } | |
| @@ -236,7 +236,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 236 | 236 | } | |
| 237 | 237 | } | |
| 238 | 238 | } | |
| 239 | - | frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars) | |
| 239 | + | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars) | |
| 240 | 240 | } | |
| 241 | 241 | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 242 | 242 | if o.paletteActive && !state.floating.visible { | |
M internal/usecase/daemon/render_test.go +2 -2
| @@ -1175,10 +1175,10 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1175 | 1175 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1176 | 1176 | p.screen.Write([]byte("ab\r\ncd")) | |
| 1177 | 1177 | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 1178 | - | mode := scopy.NewMode(document) | |
| 1178 | + | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1179 | 1179 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1180 | 1180 | ||
| 1181 | - | frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{}) | |
| 1181 | + | frame, damage := composeCopyClientFrame(mode, target, base, barState{}) | |
| 1182 | 1182 | ||
| 1183 | 1183 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 1184 | 1184 | require.Equal(t, "##ab ", rowText(frame.Row(3))) | |
M internal/usecase/visualsearch/model.go +1 -1
| @@ -106,7 +106,7 @@ func (m *Model) Matches() []scopy.SearchMatch { | |||
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | 108 | func (m *Model) refresh() { | |
| 109 | - | m.matches = scopy.FindMatches(m.snapshot, m.input.Value()) | |
| 109 | + | m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value()) | |
| 110 | 110 | m.clamp() | |
| 111 | 111 | m.ensureVisible(defaultVisibleRows) | |
| 112 | 112 | } | |