detached c4af702

Merge pull request #96 from bnema/feature/scroll-copy-selection

feat(copy): add precise scrollback selection
commit
c4af702fa3cbc247896a9a7f0b8b66774894ef96
author
bnema · · 2026-07-15 11:13 UTC
committer
GitHub · · 2026-07-15 11:13 UTC
merge of
a3000d8 + b268ad5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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.
7070
7171 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.
7272
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+
7387 ## Palette anchor
7488
7589 `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) {
3131 var warnings []domain.Warning
3232 seenBindingKeys := make(map[string]bool)
3333 seenFloatingKeys := make(map[string]bool)
34+ seenCopyKeys := make(map[string]bool)
3435 seenPaletteKeys := make(map[string]bool)
3536 seenTabsKeys := make(map[string]bool)
3637
@@ -112,6 +113,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
112113 continue
113114 }
114115 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
115124 case key == "floating.command":
116125 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
117126 command, ok := parseBarCommand(value)
@@ -313,6 +322,26 @@ func parseBarCommand(value string) (string, bool) {
313322 return value, true
314323 }
315324
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+
316345 func parseTheme(value string) (domain.ThemeMode, bool) {
317346 switch strings.ToLower(strings.TrimSpace(value)) {
318347 case "auto", "":
M internal/adapters/config/config_test.go +68 -0
@@ -344,6 +344,9 @@ func TestParse(t *testing.T) {
344344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
345345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
346346 }
347+ if tt.want.Copy.WordSeparators == "" {
348+ tt.want.Copy = domain.Defaults().Copy
349+ }
347350 got, warnings, err := Parse(strings.NewReader(tt.input))
348351 if err != nil {
349352 t.Fatalf("Parse() error = %v", err)
@@ -419,6 +422,71 @@ func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
419422 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
420423 }
421424
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+
422490 func TestParsePreservesBindingFileOrder(t *testing.T) {
423491 t.Parallel()
424492
M internal/domain/config.go +12 -0
@@ -5,6 +5,9 @@ import "time"
55 // MinBarInterval is the minimum supported bar refresh interval.
66 const MinBarInterval = time.Second
77
8+// DefaultWordSeparators matches tmux's practical shell-oriented word boundaries.
9+const DefaultWordSeparators = " -_@"
10+
811 // ThemeMode selects how vev derives its color scheme.
912 type ThemeMode int
1013
@@ -47,6 +50,11 @@ type BarConfig struct {
4750 Interval time.Duration
4851 }
4952
53+// CopyConfig contains copy-mode text selection settings.
54+type CopyConfig struct {
55+ WordSeparators string
56+}
57+
5058 // PaletteConfig contains palette placement settings. A false AnchorSet uses the
5159 // adaptive automatic placement.
5260 type PaletteConfig struct {
@@ -77,6 +85,7 @@ type Config struct {
7785 BindingEntries []ConfigEntry
7886 Codes map[string]string
7987 Snapshot SnapshotConfig
88+ Copy CopyConfig
8089 Palette PaletteConfig
8190 Floating FloatingConfig
8291 Tabs TabsConfig
@@ -103,6 +112,9 @@ func Defaults() Config {
103112 Snapshot: SnapshotConfig{
104113 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
105114 },
115+ Copy: CopyConfig{
116+ WordSeparators: DefaultWordSeparators,
117+ },
106118 Floating: FloatingConfig{
107119 Width: 80,
108120 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 (
1010 "github.com/bnema/vev/pkg/vt"
1111 )
1212
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.
1813 const OSC52MaxPayloadBytes = 75_000
1914
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.
2316 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
2820 }
2921
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.
3322 func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
3423 var history vt.HistoryView
3524 if historySource != nil {
@@ -37,11 +26,6 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
3726 }
3827 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
3928 }
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.
4529 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
4630 history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
4731 for _, row := range rows {
@@ -49,12 +33,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
4933 }
5034 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
5135 }
52-
53-// Len returns the number of document rows.
5436 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.
5837 func (s Snapshot) Row(i int) []renderer.Cell {
5938 if i < 0 {
6039 return nil
@@ -68,321 +47,388 @@ func (s Snapshot) Row(i int) []renderer.Cell {
6847 }
6948 return s.screen.Row(i)
7049 }
71-
7250 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
73- rowIndex := 0
51+ i := 0
7452 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) {
7755 stopped = true
7856 return false
7957 }
80- rowIndex++
58+ i++
8159 return true
8260 })
8361 if stopped {
8462 return
8563 }
8664 for y := range s.screen.Height {
87- if !yield(rowIndex+y, s.screen.Row(y)) {
65+ if !yield(i+y, s.screen.Row(y)) {
8866 return
8967 }
9068 }
9169 }
9270
93-// Mode stores per-client scrollback viewport and line-selection state.
9471 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.
10075
10176 type Mode struct {
77+ document *Document
78+ navigator Navigator
79+ selection Selection
10280 ViewportTop int
103- Cursor int
104- Anchor int
105- Selecting bool
10681 SearchQuery string
10782 Searches []SearchMatch
10883 SearchIndex int
10984 }
11085
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+ }
11693 return m
11794 }
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 {
12797 return nil
12898 }
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
153100 }
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)
161132 }
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
165156 }
166- runes = append(runes, unicode.ToLower(r))
167- cellIndexes = append(cellIndexes, x)
157+ changed = true
168158 }
169- return runes, cellIndexes
159+ return changed
170160 }
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
176164 }
177- return runes
165+ return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false)
178166 }
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)
182174 }
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)
185247 if len(matches) == 0 {
186- m.SearchQuery = strings.TrimSpace(query)
187248 m.Searches = nil
188249 m.SearchIndex = -1
189250 return false
190251 }
191- m.SearchQuery = strings.TrimSpace(query)
192252 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])
201255 return true
202256 }
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 {
206259 return false
207260 }
208261 m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
209262 if m.SearchIndex < 0 {
210263 m.SearchIndex += len(m.Searches)
211264 }
212- m.SetCursor(s, m.Searches[m.SearchIndex].Row)
265+ match := m.Searches[m.SearchIndex]
266+ m.moveToSearchMatch(match)
213267 return true
214268 }
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
235272 }
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 {
243275 return
244276 }
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)
254280 }
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 ""
257286 }
258- m.clamp(s)
287+ return m.selection.Text(m.document)
259288 }
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 {
265291 return
266292 }
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
275296 }
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
279299 }
280- return lo, hi, true
300+ m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
281301 }
282302
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)
287346 }
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)
293353 }
294- return strings.Join(lines, "\n")
354+ return rs
295355 }
296356
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() {
303367 src := m.ViewportTop + y
304- if src >= s.Len() {
368+ if src >= d.Len() {
305369 break
306370 }
307371 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)
313376 }
314377 }
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+ }
318387 }
319388 }
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)
322392 }
323393 }
324- style := inverseStyle()
394+ status := inverseStyle()
325395 if len(styles) > 0 {
326- style = styles[0]
396+ status = styles[0]
327397 }
328- drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)
398+ drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status)
329399 return frame
330400 }
331-
332401 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) {
334403 return SearchMatch{}, false
335404 }
336- match := m.Searches[m.SearchIndex]
337- return match, match.Row == row
405+ v := m.Searches[m.SearchIndex]
406+ return v, v.Row == row
338407 }
339-
340408 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
341409 if idx >= len(styles) {
342410 return renderer.Style{}, false
343411 }
344412 return styles[idx], true
345413 }
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()) {
349416 dst.Inverse = true
350417 return
351418 }
352- *dst = selection
419+ *dst = style
353420 }
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 }
376422 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
377423 for i := range row {
378424 row[i] = renderer.BlankCell()
379425 }
380426 text := " [SCROLL] "
381- if m.Selecting {
427+ if m.selection.Enabled {
382428 text = " [SELECT] "
383429 }
384430 if total > 0 {
385- text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "
431+ text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
386432 } else {
387433 text += "0/0 "
388434 }
@@ -400,21 +446,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
400446 }
401447 }
402448
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.
406449 func OSC52(text string) [][]byte {
407450 if len([]byte(text)) > OSC52MaxPayloadBytes {
408451 return nil
409452 }
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")}
412454 }
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.
418455 func OSC52FromBase64(b64 string) []byte {
419456 decoded, err := base64.StdEncoding.DecodeString(b64)
420457 if err != nil || len(decoded) > OSC52MaxPayloadBytes {
@@ -422,17 +459,22 @@ func OSC52FromBase64(b64 string) []byte {
422459 }
423460 return []byte("\x1b]52;c;" + b64 + "\x07")
424461 }
425-
426462 func strconvItoa(n int) string {
427463 if n == 0 {
428464 return "0"
429465 }
430- var buf [20]byte
431- i := len(buf)
466+ var b [20]byte
467+ i := len(b)
432468 for n > 0 {
433469 i--
434- buf[i] = byte('0' + n%10)
470+ b[i] = byte('0' + n%10)
435471 n /= 10
436472 }
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
438480 }
M internal/usecase/copy/mode_test.go +206 -589
@@ -6,636 +6,253 @@ import (
66 "strings"
77 "testing"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/pkg/renderer"
1011 "github.com/bnema/vev/pkg/vt"
1112 "github.com/stretchr/testify/require"
1213 )
1314
14-var benchmarkSnapshotSink Snapshot
15-
1615 func row(text string) []renderer.Cell {
17- result := make([]renderer.Cell, 0, len([]rune(text)))
16+ cells := make([]renderer.Cell, 0, len([]rune(text)))
1817 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})
4619 }
4720 return cells
4821 }
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
171149 }{
172150 {
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"},
185156 },
186- wantCursor: 2,
187- wantTop: 2,
188- wantAnchor: 2,
189- wantSelecting: true,
190157 },
191158 {
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: "Ä界ä界Ä界"},
196165 },
197- wantCursor: 4,
198- wantTop: 2,
199- wantAnchor: 1,
200- wantSelecting: true,
201166 },
202- }
203-
204- for _, tt := range tests {
167+ } {
205168 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))
212170 })
213171 }
214172 }
215173
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})
380177 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"))
451179 }
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))
452184
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"))
492187 }
493188
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")
559198 }
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}))
566202
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()
581205 })
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)
583207 }
584208
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) {
607210 const (
608- width = 120
609- height = 40
610- historyRows = 10_000
211+ rows = 10_000
212+ height = 4
611213 )
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")
616217 }
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")
620221 }
621222
622223 b.ReportAllocs()
623224 b.ResetTimer()
624- for b.Loop() {
625- benchmarkSnapshotSink = NewSnapshot(history, screen)
225+ for range b.N {
226+ _ = m.Render()
626227 }
627228 }
628229
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+ })
639251 }
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))
641258 }
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) {
2929 d.restoreProcessAllowlist.Store(&allowlist)
3030 floating := cfg.Floating
3131 d.floatingConfig.Store(&floating)
32+ copyConfig := cfg.Copy
33+ d.copyConfig.Store(&copyConfig)
3234 palette := cfg.Palette
3335 d.paletteConfig.Store(&palette)
3436 tabs := cfg.Tabs
@@ -182,6 +184,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
182184 d.log.Warn("config warning", "msg", w.Msg)
183185 }
184186
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+
185194 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
186195 if cfg := d.paletteConfig.Load(); cfg != nil {
187196 return *cfg
M internal/usecase/daemon/config_test.go +45 -0
@@ -50,6 +50,51 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
5050 require.Equal(t, "NT", cmd.Code)
5151 }
5252
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+
5398 func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
5499 d := newTestDaemon(t, nil, stubClock{})
55100 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
33
44 import (
55 "strconv"
6+ "time"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
@@ -16,6 +17,17 @@ import (
1617
1718 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
1819
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+
1931 func copyTargetPane(rt *overlayRuntime) *pane {
2032 if rt == nil {
2133 return nil
@@ -31,27 +43,24 @@ func copyTargetPane(rt *overlayRuntime) *pane {
3143 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
3244 rt := ac.overlays
3345 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 {
3547 rt.copyMu.Unlock()
3648 return
3749 }
38- document := rt.copySnapshot
39- if delta > 0 && rt.copyMode.AtBottom(*document) {
50+ if delta > 0 && rt.copyMode.AtBottom() {
4051 rt.clearCopyModeLocked()
4152 rt.copyMu.Unlock()
4253 d.invalidateRender(sess, ac, true, "copymode.go")
4354 return
4455 }
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()
4758 if exit {
4859 rt.clearCopyModeLocked()
4960 }
5061 rt.copyMu.Unlock()
51-
5262 d.invalidateRender(sess, ac, true, "copymode.go")
5363 }
54-
5564 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
5665 tb := sess.activeTab()
5766 if tb == nil {
@@ -63,23 +72,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
6372 if p == nil {
6473 return
6574 }
66- // Capture the live pane under pane.mu; all subsequent copy interaction uses
67- // the immutable document while holding only copyMu.
6875 p.mu.Lock()
69- document := scopy.NewSnapshot(p.history, p.screen.Frame)
76+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
7077 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) {
7281 return
7382 }
7483 d.invalidateRender(sess, ac, true, "copymode.go")
7584 }
7685
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 {
8390 return false
8491 }
8592 mode := scopy.NewMode(document)
@@ -92,26 +99,33 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
9299 rt.copyPending = nil
93100 rt.copyMode = nil
94101 rt.copyCandidate = mode
95- rt.copySnapshot = &document
102+ rt.copyDocument = document
96103 rt.copyPane = p
97104 rt.copySearch = nil
98105 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
102112 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
106117 if valid {
107118 tb.mu.Lock()
108119 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
109120 tb.mu.Unlock()
110121 }
111-
112122 rt.copyMu.Lock()
113123 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)
115129 return false
116130 }
117131 if !valid {
@@ -120,52 +134,76 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
120134 }
121135 rt.copyCandidate = nil
122136 rt.copyMode = mode
137+ if activate != nil {
138+ activate(rt, mode)
139+ }
123140 return true
124141 }
125142
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) {
127147 rt := ac.overlays
128148 if ev.Button != mouse.Left {
129149 return
130150 }
131151 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 {
133155 rt.copyMu.Unlock()
134156 return
135157 }
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
146158 changed := false
147159 switch ev.Type {
148160 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
157166 }
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
161194 }
162- rt.copyMode.ExtendTo(*document, absRow)
163- changed = true
164195 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)
166205 }
167206 rt.copyMu.Unlock()
168-
169207 if changed {
170208 d.invalidateRender(sess, ac, true, "copymode.go")
171209 }
@@ -178,7 +216,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
178216 return
179217 }
180218 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 {
182220 rt.copyPending = nil
183221 d.stopCopyPendingTimerLocked(ac)
184222 rt.copyMu.Unlock()
@@ -186,61 +224,71 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
186224 }
187225 if len(rt.copyPending) > 0 {
188226 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...)
193228 rt.copyPending = nil
194229 }
195- document := rt.copySnapshot
196230 if rt.copySearch != nil {
197- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
231+ changed, close, accepted := d.routeCopySearchInputLocked(rt, data)
198232 rt.copyMu.Unlock()
199- if changed || closeSearch || accepted {
233+ if changed || close || accepted {
200234 d.invalidateRender(sess, ac, true, "copymode.go")
201235 }
202236 return
203237 }
204- changed := false
205- copyOut := false
206- exit := false
238+ changed, copyOut, exit := false, false, false
207239 for i := 0; i < len(data); i++ {
208240 switch data[i] {
241+ case 'h':
242+ rt.copyMode.Left()
243+ changed = true
209244 case 'j':
210- rt.copyMode.Move(*document, 1)
245+ rt.copyMode.Down()
211246 changed = true
212247 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()
214261 changed = true
215262 case 'g':
216- rt.copyMode.Top(*document)
263+ rt.copyMode.Top()
217264 changed = true
218265 case 'G':
219- rt.copyMode.Bottom(*document)
266+ rt.copyMode.Bottom()
220267 changed = true
221268 case ' ', 'v':
222- rt.copyMode.ToggleSelection()
269+ rt.copyMode.ToggleLineSelection()
223270 changed = true
224271 case '/':
225- rt.copySearch = visualsearch.New(*document)
272+ rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot())
226273 rt.copySearchPending = nil
227274 changed = true
228275 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
231279 }
232280 i = len(data)
233281 case 'n':
234- changed = rt.copyMode.NextSearchMatch(*document, 1) || changed
282+ changed = rt.copyMode.NextSearchMatch(1) || changed
235283 case 'N':
236- changed = rt.copyMode.NextSearchMatch(*document, -1) || changed
284+ changed = rt.copyMode.NextSearchMatch(-1) || changed
237285 case '\r', '\n', 'y':
238286 copyOut = true
239287 exit = true
240288 case 'q', 0x03, 0x1b:
241289 if data[i] == 0x1b {
242290 tail := data[i:]
243- consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)
291+ consumed, ok := routeCopyEscape(rt.copyMode, tail)
244292 if ok {
245293 i += consumed - 1
246294 changed = true
@@ -260,14 +308,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
260308 }
261309 text := ""
262310 if copyOut {
263- text = rt.copyMode.SelectedText(*document)
311+ text = rt.copyMode.SelectedText()
264312 }
265313 if exit {
266314 d.stopCopyPendingTimerLocked(ac)
267315 rt.clearCopyModeLocked()
268316 }
269317 rt.copyMu.Unlock()
270-
271318 if copyOut && text != "" {
272319 chunks := scopy.OSC52(text)
273320 for _, chunk := range chunks {
@@ -285,63 +332,34 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
285332 }
286333 rt.copyMu.Unlock()
287334 }
288- if exit {
289- d.invalidateRender(sess, ac, true, "copymode.go")
290- return
291- }
292- if changed {
335+ if exit || changed {
293336 d.invalidateRender(sess, ac, true, "copymode.go")
294337 }
295338 }
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 }})
324346 if changed && !closeSearch {
325- d.previewCopySearchSelectionLocked(rt, snap)
347+ d.previewCopySearchSelectionLocked(rt)
326348 }
327349 if closeSearch {
328350 rt.copySearch = nil
329351 rt.copySearchPending = nil
330352 }
331- return changed, closeSearch, accepted
353+ return
332354 }
333-
334-func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
355+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
335356 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
336357 return
337358 }
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})
341361 }
342- rt.copyMode.SetCursor(snap, match.Row)
343362 }
344-
345363 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
346364 rt := ac.overlays
347365 mode := rt.copyMode
@@ -361,53 +379,44 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
361379 }
362380 rt.clearCopyModeLocked()
363381 rt.copyMu.Unlock()
364-
365382 if sess := ac.currentSession(); sess != nil {
366383 d.invalidateRender(sess, ac, true, "copymode.go")
367384 }
368385 })
369386 }
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) {
377389 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
378390 switch data[2] {
379391 case 'A':
380- m.Move(snap, -1)
392+ m.Up()
381393 return 3, true
382394 case 'B':
383- m.Move(snap, 1)
395+ m.Down()
384396 return 3, true
385397 }
386398 }
387399 if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
388400 switch data[2] {
389401 case '5':
390- m.Page(snap, -1)
402+ m.Page(-1)
391403 return 4, true
392404 case '6':
393- m.Page(snap, 1)
405+ m.Page(1)
394406 return 4, true
395407 }
396408 }
397409 return 0, false
398410 }
399-
400411 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')
403413 }
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 {
407416 return frame, nil
408417 }
409418 styles := newThemeStyles(bars.theme)
410- copyFrame := mode.Render(*document, styles.copyStatus, styles.selection)
419+ copyFrame := mode.Render(styles.copyStatus, styles.selection)
411420 bodyRows := max(copyFrame.Height-1, 0)
412421 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
413422 dstX := max(target.X, 0)
@@ -419,10 +428,8 @@ func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target d
419428 }
420429 }
421430 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()
426433 }
427434 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
428435 return frame, []renderer.Damage{renderer.FullRedraw()}
M internal/usecase/daemon/copymode_lifecycle_test.go +203 -23
@@ -1,10 +1,10 @@
11 package daemon
22
33 import (
4+ "fmt"
45 "testing"
56 "time"
67
7- "github.com/stretchr/testify/assert"
88 "github.com/stretchr/testify/require"
99
1010 "github.com/bnema/vev/internal/domain"
@@ -19,10 +19,17 @@ func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
1919 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
2020 d.enterCopyMode(sess, ac)
2121 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()
2225
2326 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
2427
2528 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()
2633 }
2734
2835 func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
@@ -34,11 +41,18 @@ func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
3441 }()
3542 d.enterCopyMode(sess, ac)
3643 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()
3747
3848 require.True(t, sess.switchTab(1))
3949 d.activateTab(sess, sess.activeTab())
4050
4151 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()
4256 }
4357
4458 func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
@@ -54,17 +68,22 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
5468 sess.client = ac
5569 ac.setSession(sess)
5670 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)
5872 closing.mu.Unlock()
5973 ac.overlays.copyMu.Lock()
6074 ac.overlays.copyPane = closing
61- ac.overlays.copySnapshot = &document
75+ ac.overlays.copyDocument = document
6276 ac.overlays.copyMode = scopy.NewMode(document)
77+ seedCopyInteractionLocked(ac.overlays, closing, document)
6378 ac.overlays.copyMu.Unlock()
6479
6580 require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
6681 require.False(t, ac.overlays.copyActive())
6782 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()
6887 }
6988
7089 func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
@@ -94,39 +113,200 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
94113 tb := sess.activeTab()
95114 p := tb.focusedPane()
96115 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)
98117 p.mu.Unlock()
99118
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+
105141 result := make(chan bool, 1)
106142 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)
108145 }()
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")
118150 }
151+
119152 renderSnapshot := ac.overlays.SnapshotForRender()
120153 candidateWasRenderable := renderSnapshot.copyActive
121154 renderSnapshot.Unlock()
122- sess.mu.Unlock()
155+ require.True(t, sess.switchTab(1))
156+ close(resume)
157+ resumed = true
123158
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+ }
125165 require.False(t, candidateWasRenderable)
126166 require.False(t, ac.overlays.copyActive())
127167 require.Nil(t, copyTargetPane(ac.overlays))
128168 }
129169
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+
130310 func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
131311 for _, tc := range []struct {
132312 name string
@@ -157,11 +337,11 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157337 tb := sess.activeTab()
158338 p := tb.focusedPane()
159339 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)
161341 p.mu.Unlock()
162342 tc.invalidate(sess, tb, p)
163343
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))
165345 require.False(t, ac.overlays.copyActive())
166346 require.Nil(t, copyTargetPane(ac.overlays))
167347 })
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) {
114114 pane.mu.Lock()
115115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116116 pane.mu.Unlock()
117- mode := scopy.NewMode(snap)
117+ mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118118 bars := barState{status: sess.statusSegments(true)}
119119
120120 base := renderer.NewFrame(80, 25)
@@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133133 go func() {
134134 defer wg.Done()
135135 for range 200 {
136- _, _ = composeCopyClientFrame(mode, &snap, target, base, bars)
136+ _, _ = composeCopyClientFrame(mode, target, base, bars)
137137 }
138138 }()
139139 wg.Wait()
@@ -148,7 +148,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
148148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149149 tb.focusedPane().screen.Write([]byte("live"))
150150 snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
151- mode := scopy.NewMode(snap)
151+ mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
152152
153153 bars := barState{status: sess.statusSegments(true)}
154154 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
@@ -160,7 +160,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
160160 }},
161161 bars: bars,
162162 }, 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)
164164
165165 require.Equal(t, 80, frame.Width)
166166 require.Equal(t, 25, frame.Height)
@@ -220,7 +220,7 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
220220
221221 require.Nil(t, ac.overlays.copySearch)
222222 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)
224224 select {
225225 case got := <-writes:
226226 t.Fatalf("batched visual search input forwarded to PTY: %q", got)
@@ -257,14 +257,14 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
257257 d.handleInput(sess, ac, []byte("\r"))
258258 awaitFrame(t, sends, ports.MsgOutput)
259259 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)
261261
262262 d.handleInput(sess, ac, []byte("n"))
263263 awaitFrame(t, sends, ports.MsgOutput)
264- require.Equal(t, 1, ac.overlays.copyMode.Cursor)
264+ require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row)
265265 d.handleInput(sess, ac, []byte("N"))
266266 awaitFrame(t, sends, ports.MsgOutput)
267- require.Equal(t, 0, ac.overlays.copyMode.Cursor)
267+ require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
268268
269269 select {
270270 case got := <-writes:
@@ -289,12 +289,12 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
289289 d.handleInput(sess, ac, []byte("alpha"))
290290 awaitFrame(t, sends, ports.MsgOutput)
291291 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")
293293
294294 d.handleInput(sess, ac, []byte{0x0e})
295295 awaitFrame(t, sends, ports.MsgOutput)
296296 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")
298298 }
299299
300300 func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
@@ -310,11 +310,11 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
310310 d.handleInput(sess, ac, []byte("/alpha"))
311311 awaitFrame(t, sends, ports.MsgOutput)
312312 require.NotNil(t, ac.overlays.copySearch)
313- cursor := ac.overlays.copyMode.Cursor
313+ cursor := ac.overlays.copyMode.Cursor().Row
314314
315315 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
316316 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)
318318
319319 d.handleInput(sess, ac, []byte("\x1b"))
320320 awaitFrame(t, sends, ports.MsgOutput)
@@ -517,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
517517 }
518518
519519 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)
521521 })
522522 }
523523 }
@@ -790,11 +790,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
790790
791791 d.enterCopyMode(sess, ac)
792792 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)
794794
795795 d.copyWheel(sess, ac, -3)
796796 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)
798798
799799 d.copyWheel(sess, ac, 3)
800800 awaitFrame(t, sends, ports.MsgOutput)
@@ -813,7 +813,9 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
813813
814814 d.enterCopyMode(sess, ac)
815815 awaitFrame(t, sends, ports.MsgOutput)
816+ ac.overlays.copyMu.Lock()
816817 viewportTop := ac.overlays.copyMode.ViewportTop
818+ ac.overlays.copyMu.Unlock()
817819 inner := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 23}, d.currentFloatingConfig()).Inner
818820
819821 // Copy body row k renders at wire row inner.Y+k+2 (top bar + content
@@ -821,15 +823,15 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
821823 press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
822824 d.handleInput(sess, ac, []byte(press))
823825 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)
825827
826828 motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
827829 d.handleInput(sess, ac, []byte(motion))
828830 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)
833835 }
834836
835837 func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
@@ -864,7 +866,7 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
864866 copy(pane.screen.Frame.Row(0), testRow("alpha"))
865867 copy(pane.screen.Frame.Row(1), testRow("bravo"))
866868
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"))
868870 mustOutputData(t, sends)
869871 require.NotNil(t, ac.overlays.copyMode)
870872 ac.overlays.copyMu.Lock()
@@ -876,6 +878,6 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
876878 out := awaitFrame(t, sends, ports.MsgOutput)
877879 msg, err := ports.UnmarshalOutput(out.Payload)
878880 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"
880882 require.Equal(t, want, string(msg.Data))
881883 }
M internal/usecase/daemon/daemon.go +30 -21
@@ -91,27 +91,33 @@ type Daemon struct {
9191 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
9292 // interval between JRS validation and its committed hand-off.
9393 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{}
115121 // snapshotFinalJobs coalesces terminal captures by session when the bounded
116122 // regular queue is full. It retains at most snapshotFinalQueueCapacity named
117123 // sessions, each with only its newest terminal state while the worker blocks.
@@ -130,6 +136,7 @@ type Daemon struct {
130136 codeOverrides atomic.Pointer[map[string]string]
131137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
132138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139+ copyConfig atomic.Pointer[domain.CopyConfig]
133140 paletteConfig atomic.Pointer[domain.PaletteConfig]
134141 tabsConfig atomic.Pointer[domain.TabsConfig]
135142 themeMode atomic.Uint32
@@ -331,6 +338,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
331338 defaults := domain.Defaults()
332339 defaultFloating := defaults.Floating
333340 d.floatingConfig.Store(&defaultFloating)
341+ defaultCopy := defaults.Copy
342+ d.copyConfig.Store(&defaultCopy)
334343 defaultPalette := defaults.Palette
335344 d.paletteConfig.Store(&defaultPalette)
336345 for _, o := range opts {
M internal/usecase/daemon/input.go +24 -128
@@ -5,7 +5,6 @@ import (
55 "bytes"
66 "strconv"
77
8- scopy "github.com/bnema/vev/internal/usecase/copy"
98 "github.com/bnema/vev/internal/usecase/keys"
109 "github.com/bnema/vev/internal/usecase/layout"
1110 "github.com/bnema/vev/internal/usecase/mouse"
@@ -33,6 +32,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3332 }
3433
3534 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
35+ frameEvent := ev
3636 ac.initOverlays()
3737 rt := ac.overlays
3838 if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
@@ -40,82 +40,21 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
4040 }
4141 sess := ac.currentSession()
4242 if sess == nil {
43+ invalidateRejectedLeftPointer(rt, ev)
4344 return
4445 }
4546 tb := sess.activeTab()
4647 if tb == nil {
48+ invalidateRejectedLeftPointer(rt, ev)
4749 return
4850 }
4951
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) {
11958 return
12059 }
12160
@@ -125,9 +64,13 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
12564 if floatingVisible {
12665 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
12766 tb.mu.Unlock()
67+ invalidateRejectedLeftPointer(rt, ev)
12868 return
12969 }
13070 tb.mu.Unlock()
71+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, floating, frameEvent) {
72+ return
73+ }
13174 ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
13275 d.handleTerminalMouse(sess, ac, floating, ev, true, true)
13376 return
@@ -141,12 +84,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
14184 if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
14285 if !isMouseFocusPress(ev) {
14386 tb.mu.Unlock()
87+ invalidateRejectedLeftPointer(rt, ev)
14488 return
14589 }
14690 oldFocus := focusedID
14791 focusPlacementLocked(tb, pl.ID)
14892 d.applyLayoutLocked(tb)
14993 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)
15098 if pl.ID != oldFocus {
15199 d.exitCopyMode(ac)
152100 d.refreshPaneTitleOnFocus(sess, pl.ID)
@@ -167,6 +115,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
167115 hoveredFocused = pl.ID == oldFocus
168116 tb.mu.Unlock()
169117 if p == nil {
118+ invalidateRejectedLeftPointer(rt, ev)
170119 return
171120 }
172121 if isMouseFocusPress(ev) && pl.ID != oldFocus {
@@ -181,14 +130,19 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
181130 } else {
182131 if multi {
183132 tb.mu.Unlock()
133+ invalidateRejectedLeftPointer(rt, ev)
184134 return
185135 }
186136 p = tb.focusedPane()
187137 tb.mu.Unlock()
188138 if p == nil {
139+ invalidateRejectedLeftPointer(rt, ev)
189140 return
190141 }
191142 }
143+ if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, tb, p, frameEvent) {
144+ return
145+ }
192146 d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
193147 }
194148
@@ -196,15 +150,10 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
196150 if p == nil {
197151 return
198152 }
199- rt := ac.overlays
200153 p.mu.Lock()
201154 childRows := p.screen.Frame.Height
202155 mouseMode, mouseSGR := p.screen.MouseMode()
203156 altScreen := p.screen.AltScreenActive()
204- scrollbackRows := 0
205- if p.history != nil {
206- scrollbackRows = p.history.Len()
207- }
208157 p.mu.Unlock()
209158
210159 if mouseMode != 0 {
@@ -220,59 +169,6 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
220169 }
221170
222171 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- }
276172 case mouse.WheelUp:
277173 if altScreen {
278174 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 (
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ scopy "github.com/bnema/vev/internal/usecase/copy"
1819 "github.com/bnema/vev/internal/usecase/keys"
1920 "github.com/bnema/vev/internal/usecase/layout"
21+ "github.com/bnema/vev/internal/usecase/mouse"
2022 "github.com/bnema/vev/internal/usecase/picker"
2123 "github.com/bnema/vev/pkg/vt"
2224 )
@@ -844,7 +846,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
844846 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
845847 data := mustOutputData(t, sends)
846848 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)
848850 require.Contains(t, string(data), "[SCROLL]")
849851
850852 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -945,17 +947,62 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
945947 }
946948
947949 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"))
949951 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"))
951953 mustOutputData(t, sends)
952954 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+ }
955961 require.Equal(t, 0, lo)
956962 require.Equal(t, 2, hi)
957963 }
958964
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+
9591006 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
9601007 p, _ := newBlockingPTY(t)
9611008 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -965,11 +1012,15 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
9651012
9661013 d.enterCopyMode(sess, ac)
9671014 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"))
9691016 mustOutputData(t, sends)
9701017 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+ }
9731024 require.Equal(t, 0, lo)
9741025 require.Equal(t, 1, hi)
9751026
@@ -981,7 +1032,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
9811032 }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
9821033 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
9831034 require.NoError(t, err)
984- require.Equal(t, "alpha\nbravo", string(decoded))
1035+ require.Equal(t, "alpha"+strings.Repeat(" ", 75)+"\nb", string(decoded))
9851036 require.Nil(t, ac.overlays.copyMode)
9861037
9871038 exitPaint := string(mustOutputData(t, sends))
@@ -1001,16 +1052,16 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
10011052 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
10021053
10031054 // 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"))
10051056 require.Nil(t, ac.overlays.copyMode)
10061057
10071058 // Release lands on the status row: must still clear the press state,
10081059 // 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"))
10101061
10111062 // A brand new button hold starts with a Press on the status row: must
10121063 // 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"))
10141065
10151066 // Motion onto a content row must not resurrect a stale anchor.
10161067 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
@@ -1024,6 +1075,28 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
10241075 // a Press on the status row left the previous drag state in place and a
10251076 // following Motion on a content row silently extended the old selection
10261077 // 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+
10271100 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10281101 p, _ := newBlockingPTY(t)
10291102 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1036,61 +1109,66 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10361109 mustOutputData(t, sends)
10371110
10381111 // 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"))
10401113 mustOutputData(t, sends)
10411114 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+ }
10441121 require.Equal(t, 0, lo)
10451122 require.Equal(t, 1, hi)
10461123
10471124 // 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"))
10501127
10511128 // Motion onto a content row must be a no-op: the status-row press must
10521129 // have cleared copyPressRowValid/copyDragging.
10531130 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
10541131
10551132 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+ }
10581139 require.Equal(t, 0, lo, "anchor must not have moved")
10591140 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
10601141 }
10611142
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) {
10681147 p, _ := newBlockingPTY(t)
10691148 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
10701149 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
10711150 require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
10721151
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"))
10761154
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.
10791156 for range 5 {
10801157 sess.tabs[0].focusedPane().history.Append(testRow("evicted"))
10811158 }
10821159
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.
10871161 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
10881162
10891163 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+ }
10921170 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")
10941172 }
10951173
10961174 func TestMouseSplitReportPreservesOrder(t *testing.T) {
@@ -1135,6 +1213,137 @@ func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing
11351213 require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
11361214 }
11371215
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+
11381347 func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
11391348 cases := []struct {
11401349 name string
@@ -1199,11 +1408,15 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
11991408 d.enterCopyMode(sess, ac)
12001409 mustOutputData(t, sends)
12011410
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"))
12031412 mustOutputData(t, sends)
12041413
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+ }
12071420 require.Equal(t, 0, lo)
12081421 require.Equal(t, 9, hi)
12091422 }
@@ -1252,3 +1465,271 @@ func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
12521465 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
12531466 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
12541467 }
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) {
144144 }
145145 rt.copyMu.Lock()
146146 rt.clearCopyModeLocked()
147- rt.copyPressRowValid = false
148- rt.copyDragging = false
149- rt.normalMousePressValid = false
150147 rt.copyMu.Unlock()
151148 }
M internal/usecase/daemon/overlay_runtime.go +75 -23
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "sync"
5+ "time"
56
67 scopy "github.com/bnema/vev/internal/usecase/copy"
78 "github.com/bnema/vev/internal/usecase/palette"
@@ -33,22 +34,38 @@ type overlayRuntime struct {
3334 promptSubmit func(string) error
3435 promptPending []byte
3536
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
5269 }
5370
5471 func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
@@ -107,13 +124,47 @@ func (rt *overlayRuntime) copySearchActive() bool {
107124 return rt.copySearch != nil
108125 }
109126
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+
110160 func (rt *overlayRuntime) clearCopyModeLocked() {
111161 rt.copyMode = nil
112162 rt.copyCandidate = nil
113- rt.copySnapshot = nil
163+ rt.copyDocument = nil
114164 rt.copyPane = nil
115165 rt.copySearch = nil
116166 rt.copySearchPending = nil
167+ rt.invalidateCopyPointerLocked(true)
117168 }
118169
119170 func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
@@ -122,11 +173,14 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
122173 }
123174 rt.copyMu.Lock()
124175 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)
127182 }
128- rt.clearCopyModeLocked()
129- return true
183+ return active || prePublication
130184 }
131185
132186 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
@@ -158,7 +212,6 @@ type overlayRenderSnapshot struct {
158212
159213 copyActive bool
160214 copyMode *scopy.Mode
161- copySnapshot *scopy.Snapshot
162215 copyPane *pane
163216 copySearchModel *visualsearch.Model
164217 copyFeedback string
@@ -195,7 +248,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
195248 rt.copyMu.Lock()
196249 snap.copyActive = rt.copyMode != nil
197250 snap.copyPane = rt.copyPane
198- snap.copySnapshot = rt.copySnapshot
199251 if rt.copyMode != nil {
200252 copyModeValue := *rt.copyMode
201253 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) {
114114 ac := &attachedClient{}
115115 ac.initOverlays()
116116 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"))
119119 search := visualsearch.New(snap)
120120 for _, r := range "alpha" {
121121 search.Insert(r)
@@ -145,46 +145,72 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
145145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
146146 }
147147
148-func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.T) {
148+func TestOverlayRuntimeSnapshotCarriesCopyModeDocumentAndClearReleasesIt(t *testing.T) {
149149 ac := &attachedClient{}
150150 ac.initOverlays()
151151 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)
153153
154154 ac.overlays.copyMu.Lock()
155155 ac.overlays.copyMode = scopy.NewMode(document)
156156 ac.overlays.copyPane = p
157- ac.overlays.copySnapshot = &document
157+ ac.overlays.copyDocument = document
158158 ac.overlays.copyMu.Unlock()
159159
160160 renderSnap := ac.overlays.SnapshotForRender()
161161 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())
163164 renderSnap.Unlock()
164165
165166 ac.overlays.copyMu.Lock()
166167 ac.overlays.clearCopyModeLocked()
167168 require.Nil(t, ac.overlays.copyPane)
168169 require.Nil(t, ac.overlays.copyMode)
169- require.Nil(t, ac.overlays.copySnapshot)
170+ require.Nil(t, ac.overlays.copyDocument)
170171 ac.overlays.copyMu.Unlock()
171172 }
172173
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) {
174199 ac := &attachedClient{}
175200 ac.initOverlays()
176201 history := newTestHistory(1)
177202 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)
179204
180205 ac.overlays.copyMu.Lock()
181206 ac.overlays.copyMode = scopy.NewMode(document)
182- ac.overlays.copySnapshot = &document
207+ ac.overlays.copyDocument = document
183208 ac.overlays.copyMu.Unlock()
184209
185210 history.Append(testRow("after"))
186211 renderSnap := ac.overlays.SnapshotForRender()
187212 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)))
190216 }
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -869,7 +869,7 @@ func (f *performanceFixture) searchMatches() int {
869869 func (f *performanceFixture) copyModeActive() bool {
870870 f.ac.overlays.copyMu.Lock()
871871 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
873873 }
874874
875875 func (f *performanceFixture) resize() {
M internal/usecase/daemon/render_capture.go +0 -1
@@ -84,7 +84,6 @@ type capturedFloatingRenderState struct {
8484 type capturedOverlayRenderState struct {
8585 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
8686 copyMode *scopy.Mode
87- copySnapshot *scopy.Snapshot
8887 copyPaneID layout.PaneID
8988 copyFeedback string
9089 paletteGuidance string
M internal/usecase/daemon/render_pipeline.go +2 -2
@@ -185,7 +185,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
185185 }
186186 o := &state.overlays
187187 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
189189 if snap.copyPane != nil {
190190 o.copyPaneID = snap.copyPane.id
191191 }
@@ -236,7 +236,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
236236 }
237237 }
238238 }
239- frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
239+ frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars)
240240 }
241241 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
242242 if o.paletteActive && !state.floating.visible {
M internal/usecase/daemon/render_test.go +2 -2
@@ -1175,10 +1175,10 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
11751175 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
11761176 p.screen.Write([]byte("ab\r\ncd"))
11771177 document := scopy.NewSnapshot(p.history, p.screen.Frame)
1178- mode := scopy.NewMode(document)
1178+ mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
11791179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
11801180
1181- frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{})
1181+ frame, damage := composeCopyClientFrame(mode, target, base, barState{})
11821182
11831183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
11841184 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 {
106106 }
107107
108108 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())
110110 m.clamp()
111111 m.ensureVisible(defaultVisibleRows)
112112 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close