detached 6af2c31

feat(copy): integrate precise keyboard selection

commit
6af2c31a9dd3126144a7bd1e1212c0ea575cc5a1
author
brice · · 2026-07-15 07:02 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
e2f71ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +551 -1065
M internal/usecase/copy/mode.go +274 -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,370 @@ 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+ return m.SetSearchMatches(query, FindMatches(m.document, query), 0)
238+}
239+func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool {
240+ if m == nil {
241+ return false
242+ }
243+ m.SearchQuery = strings.TrimSpace(query)
185244 if len(matches) == 0 {
186- m.SearchQuery = strings.TrimSpace(query)
187245 m.Searches = nil
188246 m.SearchIndex = -1
189247 return false
190248 }
191- m.SearchQuery = strings.TrimSpace(query)
192249 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)
250+ m.SearchIndex = min(max(index, 0), len(matches)-1)
251+ m.SetPosition(Pos{Row: matches[m.SearchIndex].Row, Col: matches[m.SearchIndex].Start})
201252 return true
202253 }
203-
204-func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool {
205- if len(m.Searches) == 0 || delta == 0 {
254+func (m *Mode) NextSearchMatch(delta int) bool {
255+ if m == nil || len(m.Searches) == 0 || delta == 0 {
206256 return false
207257 }
208258 m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
209259 if m.SearchIndex < 0 {
210260 m.SearchIndex += len(m.Searches)
211261 }
212- m.SetCursor(s, m.Searches[m.SearchIndex].Row)
262+ match := m.Searches[m.SearchIndex]
263+ m.SetPosition(Pos{Row: match.Row, Col: match.Start})
213264 return true
214265 }
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
266+func (m *Mode) SelectedText() string {
267+ if m == nil {
268+ return ""
235269 }
236- return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0)
270+ return m.selection.Text(m.document)
237271 }
238-
239-func (m *Mode) ToggleSelection() {
240- if m.Selecting {
241- m.Selecting = false
242- m.Anchor = -1
272+func (m *Mode) adjustViewport() {
273+ if m == nil || m.document == nil || m.document.Len() == 0 {
243274 return
244275 }
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
276+ rows := max(m.document.Height(), 1)
277+ if m.navigator.Pos.Row < m.ViewportTop {
278+ m.ViewportTop = m.navigator.Pos.Row
254279 }
255- if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom {
256- m.ViewportTop = m.Cursor - max(s.Height, 1) + 1
280+ if m.navigator.Pos.Row >= m.ViewportTop+rows {
281+ m.ViewportTop = m.navigator.Pos.Row - rows + 1
257282 }
258- m.clamp(s)
283+ m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
259284 }
260285
261-func (m *Mode) clamp(s Snapshot) {
262- total := s.Len()
263- if total == 0 {
264- m.Cursor, m.ViewportTop = 0, 0
265- return
286+func FindMatches(doc *Document, query string) []SearchMatch {
287+ query = strings.TrimSpace(query)
288+ if doc == nil || query == "" {
289+ return nil
266290 }
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)
291+ needle := lowerRunes(query)
292+ matches := []SearchMatch{}
293+ doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool {
294+ hay, indexes := searchableCells(cells)
295+ text := ""
296+ for start := 0; start+len(needle) <= len(hay); {
297+ if !slices.Equal(hay[start:start+len(needle)], needle) {
298+ start++
299+ continue
300+ }
301+ if text == "" {
302+ text = doc.LineText(row)
303+ }
304+ end := start + len(needle)
305+ cellEnd := len(cells)
306+ if end < len(indexes) {
307+ cellEnd = indexes[end]
308+ }
309+ matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})
310+ start += len(needle)
311+ }
312+ return true
313+ })
314+ return matches
270315 }
271-
272-func (m *Mode) SelectedBounds() (int, int, bool) {
273- if !m.Selecting || m.Anchor < 0 {
274- return 0, 0, false
316+func searchableCells(cells []renderer.Cell) ([]rune, []int) {
317+ rs := make([]rune, 0, len(cells))
318+ is := make([]int, 0, len(cells))
319+ for x, c := range cells {
320+ if c.Continuation {
321+ continue
322+ }
323+ r := c.Rune
324+ if r == 0 {
325+ r = ' '
326+ }
327+ rs = append(rs, unicode.ToLower(r))
328+ is = append(is, x)
275329 }
276- lo, hi := m.Anchor, m.Cursor
277- if lo > hi {
278- lo, hi = hi, lo
330+ return rs, is
331+}
332+func lowerRunes(s string) []rune {
333+ rs := []rune(s)
334+ for i, r := range rs {
335+ rs[i] = unicode.ToLower(r)
279336 }
280- return lo, hi, true
337+ return rs
281338 }
282339
283-func (m *Mode) SelectedText(s Snapshot) string {
284- lo, hi, ok := m.SelectedBounds()
285- if !ok || s.Len() == 0 {
286- return ""
340+func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
341+ if m == nil || m.document == nil {
342+ return renderer.NewFrame(0, 0)
287343 }
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)))
344+ d := m.document
345+ frame := renderer.NewFrame(d.Width(), d.Height()+1)
346+ ranges := m.selection.Ranges(d)
347+ selected := make(map[int][]CellRange, len(ranges))
348+ for _, r := range ranges {
349+ selected[r.Row] = append(selected[r.Row], r)
293350 }
294- return strings.Join(lines, "\n")
295-}
296-
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 {
351+ selection, hasSelection := optionalStyle(styles, 1)
352+ for y := range d.Height() {
303353 src := m.ViewportTop + y
304- if src >= s.Len() {
354+ if src >= d.Len() {
305355 break
306356 }
307357 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)
358+ copy(row, d.Row(src))
359+ if match, ok := m.currentSearchMatchForRow(src); ok {
360+ for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
361+ applySelectionStyle(&row[x].Style, selection, hasSelection)
313362 }
314363 }
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)
364+ covered := false
365+ for _, r := range selected[src] {
366+ covered = true
367+ for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
368+ applySelectionStyle(&row[x].Style, selection, hasSelection)
318369 }
319370 }
320- if src == m.Cursor && !lineSelected && len(row) > 0 {
321- applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
371+ if src == m.navigator.Pos.Row && !covered && len(row) > 0 {
372+ col := min(max(m.navigator.Pos.Col, 0), len(row)-1)
373+ applySelectionStyle(&row[col].Style, selection, hasSelection)
322374 }
323375 }
324- style := inverseStyle()
376+ status := inverseStyle()
325377 if len(styles) > 0 {
326- style = styles[0]
378+ status = styles[0]
327379 }
328- drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)
380+ drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status)
329381 return frame
330382 }
331-
332383 func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
333- if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
384+ if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
334385 return SearchMatch{}, false
335386 }
336- match := m.Searches[m.SearchIndex]
337- return match, match.Row == row
387+ v := m.Searches[m.SearchIndex]
388+ return v, v.Row == row
338389 }
339-
340390 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
341391 if idx >= len(styles) {
342392 return renderer.Style{}, false
343393 }
344394 return styles[idx], true
345395 }
346-
347-func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {
348- if !ok || selection.Equal(inverseStyle()) {
396+func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
397+ if !ok || style.Equal(inverseStyle()) {
349398 dst.Inverse = true
350399 return
351400 }
352- *dst = selection
353-}
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), " ")
401+ *dst = style
374402 }
375-
403+func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
376404 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
377405 for i := range row {
378406 row[i] = renderer.BlankCell()
379407 }
380408 text := " [SCROLL] "
381- if m.Selecting {
409+ if m.selection.Enabled {
382410 text = " [SELECT] "
383411 }
384412 if total > 0 {
385- text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "
413+ text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
386414 } else {
387415 text += "0/0 "
388416 }
@@ -400,21 +428,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
400428 }
401429 }
402430
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.
406431 func OSC52(text string) [][]byte {
407432 if len([]byte(text)) > OSC52MaxPayloadBytes {
408433 return nil
409434 }
410- encoded := base64.StdEncoding.EncodeToString([]byte(text))
411- return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
435+ return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
412436 }
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.
418437 func OSC52FromBase64(b64 string) []byte {
419438 decoded, err := base64.StdEncoding.DecodeString(b64)
420439 if err != nil || len(decoded) > OSC52MaxPayloadBytes {
@@ -422,17 +441,22 @@ func OSC52FromBase64(b64 string) []byte {
422441 }
423442 return []byte("\x1b]52;c;" + b64 + "\x07")
424443 }
425-
426444 func strconvItoa(n int) string {
427445 if n == 0 {
428446 return "0"
429447 }
430- var buf [20]byte
431- i := len(buf)
448+ var b [20]byte
449+ i := len(b)
432450 for n > 0 {
433451 i--
434- buf[i] = byte('0' + n%10)
452+ b[i] = byte('0' + n%10)
435453 n /= 10
436454 }
437- return string(buf[i:])
455+ return string(b[i:])
456+}
457+func abs(n int) int {
458+ if n < 0 {
459+ return -n
460+ }
461+ return n
438462 }
@@ -10,26 +10,15 @@ import (
10 "github.com/bnema/vev/pkg/vt"10 "github.com/bnema/vev/pkg/vt"
11 )11 )
12 12
13-// OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits
14-// exactly one OSC 52 sequence. Splitting one clipboard copy across multiple OSC
15-// 52 sequences replaces the clipboard repeatedly in common terminals, corrupting
16-// the intended result, so oversized selections are deferred until a terminal-
17-// specific continuation protocol is supported.
18 const OSC52MaxPayloadBytes = 75_00013 const OSC52MaxPayloadBytes = 75_000
19 14
20-// Snapshot is the immutable scrollback-mode document: history followed by the15+// Snapshot is the immutable scrollback document: sealed history plus a cloned screen.
21-// visible screen. VT HistoryView retains immutable chunks; the visible frame is
22-// cloned once when the snapshot is constructed.
23 type Snapshot struct {16 type Snapshot struct {
24- history vt.HistoryView17+ history vt.HistoryView
25- screen renderer.Frame18+ screen renderer.Frame
26- Width int19+ Width, Height int
27- Height int
28 }20 }
29 21
30-// NewSnapshot seals the current VT history tail, then freezes that immutable
31-// view and clones the visible screen. Sealing prevents repeated copy entry from
32-// cloning every row in a partial tail.
33 func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {22 func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
34 var history vt.HistoryView23 var history vt.HistoryView
35 if historySource != nil {24 if historySource != nil {
@@ -37,11 +26,6 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
37 }26 }
38 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}27 return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
39 }28 }
40-
41-// NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
42-// It is intended for callers that already have a complete document rather than
43-// a scrollback and visible frame. Height is viewport geometry, not a request to
44-// append blank document rows.
45 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {29 func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
46 history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})30 history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256})
47 for _, row := range rows {31 for _, row := range rows {
@@ -49,12 +33,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
49 }33 }
50 return Snapshot{history: history.SealAndView(), Width: width, Height: height}34 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
51 }35 }
52-
53-// Len returns the number of document rows.
54 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }36 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
55-
56-// Row returns document row i, or nil when i is out of range. History rows
57-// borrow immutable history storage and must be treated as read-only.
58 func (s Snapshot) Row(i int) []renderer.Cell {37 func (s Snapshot) Row(i int) []renderer.Cell {
59 if i < 0 {38 if i < 0 {
60 return nil39 return nil
@@ -68,321 +47,370 @@ func (s Snapshot) Row(i int) []renderer.Cell {
68 }47 }
69 return s.screen.Row(i)48 return s.screen.Row(i)
70 }49 }
71-
72 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {50 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
73- rowIndex := 051+ i := 0
74 stopped := false52 stopped := false
75- s.history.Range(func(row []renderer.Cell) bool {53+ s.history.Range(func(r []renderer.Cell) bool {
76- if !yield(rowIndex, row) {54+ if !yield(i, r) {
77 stopped = true55 stopped = true
78 return false56 return false
79 }57 }
80- rowIndex++58+ i++
81 return true59 return true
82 })60 })
83 if stopped {61 if stopped {
84 return62 return
85 }63 }
86 for y := range s.screen.Height {64 for y := range s.screen.Height {
87- if !yield(rowIndex+y, s.screen.Row(y)) {65+ if !yield(i+y, s.screen.Row(y)) {
88 return66 return
89 }67 }
90 }68 }
91 }69 }
92 70
93-// Mode stores per-client scrollback viewport and line-selection state.
94 type SearchMatch struct {71 type SearchMatch struct {
95- Row int72+ Row, Start, End int
96- Start int73+ Text string
97- End int74+} // End is exclusive display-cell offset.
98- Text string
99-}
100 75
101 type Mode struct {76 type Mode struct {
77+ document *Document
78+ navigator Navigator
79+ selection Selection
102 ViewportTop int80 ViewportTop int
103- Cursor int
104- Anchor int
105- Selecting bool
106 SearchQuery string81 SearchQuery string
107 Searches []SearchMatch82 Searches []SearchMatch
108 SearchIndex int83 SearchIndex int
109 }84 }
110 85
111-func NewMode(s Snapshot) *Mode {86+func NewMode(doc *Document) *Mode {
112- m := &Mode{Anchor: -1}87+ m := &Mode{document: doc, SearchIndex: -1}
113- m.ViewportTop = max(s.Len()-s.Height, 0)88+ if doc != nil && doc.Len() > 0 {
114- m.Cursor = max(s.Len()-1, 0)89+ m.navigator = NewNavigator(Pos{Row: doc.Len() - 1})
115- m.clamp(s)90+ m.navigator.Set(doc, m.navigator.Pos)
91+ m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0)
92+ }
116 return m93 return m
117 }94 }
118-95+func (m *Mode) Document() *Document {
119-func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }96+ if m == nil {
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 == "" {
127 return nil97 return nil
128 }98 }
129- needle := lowerRunes(query)99+ return m.document
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
153 }100 }
154-101+func (m *Mode) Cursor() Pos {
155-func searchableCells(cells []renderer.Cell) ([]rune, []int) {102+ if m == nil {
156- runes := make([]rune, 0, len(cells))103+ return Pos{}
157- cellIndexes := make([]int, 0, len(cells))104+ }
158- for x, cell := range cells {105+ return m.navigator.Pos
159- if cell.Continuation {106+}
160- continue107+func (m *Mode) Selection() Selection {
108+ if m == nil {
109+ return Selection{}
110+ }
111+ return m.selection
112+}
113+func (m *Mode) SetPosition(p Pos) bool {
114+ if m == nil {
115+ return false
116+ }
117+ changed := m.navigator.Set(m.document, p)
118+ m.adjustViewport()
119+ return changed
120+}
121+func (m *Mode) move(op func(*Document) bool, stream bool) bool {
122+ if m == nil || m.document == nil {
123+ return false
124+ }
125+ if stream && m.selection.Enabled && m.selection.Granularity == Line {
126+ m.selection.AsCharacter()
127+ }
128+ changed := op(m.document)
129+ if changed {
130+ if m.selection.Enabled {
131+ m.selection.Extend(m.navigator.Pos)
161 }132 }
162- r := cell.Rune133+ m.adjustViewport()
163- if r == 0 {134+ }
164- r = ' '135+ return changed
136+}
137+func (m *Mode) Left() bool { return m.move(m.navigator.Left, true) }
138+func (m *Mode) Right() bool { return m.move(m.navigator.Right, true) }
139+func (m *Mode) Up() bool { return m.move(m.navigator.Up, false) }
140+func (m *Mode) Down() bool { return m.move(m.navigator.Down, false) }
141+func (m *Mode) WordNext() bool { return m.move(m.navigator.WordNext, true) }
142+func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) }
143+func (m *Mode) WordEnd() bool { return m.move(m.navigator.WordEnd, true) }
144+func (m *Mode) MoveRows(delta int) bool {
145+ if delta == 0 {
146+ return false
147+ }
148+ changed := false
149+ op := m.Up
150+ if delta > 0 {
151+ op = m.Down
152+ }
153+ for range abs(delta) {
154+ if !op() {
155+ break
165 }156 }
166- runes = append(runes, unicode.ToLower(r))157+ changed = true
167- cellIndexes = append(cellIndexes, x)
168 }158 }
169- return runes, cellIndexes159+ return changed
170 }160 }
171-161+func (m *Mode) Page(pages int) bool {
172-func lowerRunes(s string) []rune {162+ if m == nil || m.document == nil {
173- runes := []rune(s)163+ return false
174- for i, r := range runes {
175- runes[i] = unicode.ToLower(r)
176 }164 }
177- return runes165+ return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false)
178 }166 }
179-167+func (m *Mode) Top() bool { return m.move(m.navigator.Top, false) }
180-func (m *Mode) Search(s Snapshot, query string) bool {168+func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) }
181- return m.SetSearchMatches(s, query, FindMatches(s, query), 0)169+func (m *Mode) AtBottom() bool {
170+ if m == nil || m.document == nil || m.document.Len() == 0 {
171+ return m != nil && m.ViewportTop == 0
172+ }
173+ return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0)
182 }174 }
183-175+func (m *Mode) ToggleLineSelection() {
184-func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool {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+ return m.SetSearchMatches(query, FindMatches(m.document, query), 0)
238+}
239+func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool {
240+ if m == nil {
241+ return false
242+ }
243+ m.SearchQuery = strings.TrimSpace(query)
185 if len(matches) == 0 {244 if len(matches) == 0 {
186- m.SearchQuery = strings.TrimSpace(query)
187 m.Searches = nil245 m.Searches = nil
188 m.SearchIndex = -1246 m.SearchIndex = -1
189 return false247 return false
190 }248 }
191- m.SearchQuery = strings.TrimSpace(query)
192 m.Searches = append(m.Searches[:0], matches...)249 m.Searches = append(m.Searches[:0], matches...)
193- if index < 0 {250+ m.SearchIndex = min(max(index, 0), len(matches)-1)
194- index = 0251+ m.SetPosition(Pos{Row: matches[m.SearchIndex].Row, Col: matches[m.SearchIndex].Start})
195- }
196- if index >= len(matches) {
197- index = len(matches) - 1
198- }
199- m.SearchIndex = index
200- m.SetCursor(s, matches[index].Row)
201 return true252 return true
202 }253 }
203-254+func (m *Mode) NextSearchMatch(delta int) bool {
204-func (m *Mode) NextSearchMatch(s Snapshot, delta int) bool {255+ if m == nil || len(m.Searches) == 0 || delta == 0 {
205- if len(m.Searches) == 0 || delta == 0 {
206 return false256 return false
207 }257 }
208 m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)258 m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
209 if m.SearchIndex < 0 {259 if m.SearchIndex < 0 {
210 m.SearchIndex += len(m.Searches)260 m.SearchIndex += len(m.Searches)
211 }261 }
212- m.SetCursor(s, m.Searches[m.SearchIndex].Row)262+ match := m.Searches[m.SearchIndex]
263+ m.SetPosition(Pos{Row: match.Row, Col: match.Start})
213 return true264 return true
214 }265 }
215-266+func (m *Mode) SelectedText() string {
216-// SetCursor moves the visual cursor to row, clamping it to the snapshot and267+ if m == nil {
217-// scrolling the viewport just enough to keep it visible.268+ return ""
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
235 }269 }
236- return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0)270+ return m.selection.Text(m.document)
237 }271 }
238-272+func (m *Mode) adjustViewport() {
239-func (m *Mode) ToggleSelection() {273+ if m == nil || m.document == nil || m.document.Len() == 0 {
240- if m.Selecting {
241- m.Selecting = false
242- m.Anchor = -1
243 return274 return
244 }275 }
245- m.Selecting = true276+ rows := max(m.document.Height(), 1)
246- m.Anchor = m.Cursor277+ if m.navigator.Pos.Row < m.ViewportTop {
247-}278+ m.ViewportTop = m.navigator.Pos.Row
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
254 }279 }
255- if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom {280+ if m.navigator.Pos.Row >= m.ViewportTop+rows {
256- m.ViewportTop = m.Cursor - max(s.Height, 1) + 1281+ m.ViewportTop = m.navigator.Pos.Row - rows + 1
257 }282 }
258- m.clamp(s)283+ m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
259 }284 }
260 285
261-func (m *Mode) clamp(s Snapshot) {286+func FindMatches(doc *Document, query string) []SearchMatch {
262- total := s.Len()287+ query = strings.TrimSpace(query)
263- if total == 0 {288+ if doc == nil || query == "" {
264- m.Cursor, m.ViewportTop = 0, 0289+ return nil
265- return
266 }290 }
267- m.Cursor = min(max(m.Cursor, 0), total-1)291+ needle := lowerRunes(query)
268- maxTop := max(total-max(s.Height, 1), 0)292+ matches := []SearchMatch{}
269- m.ViewportTop = min(max(m.ViewportTop, 0), maxTop)293+ doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool {
294+ hay, indexes := searchableCells(cells)
295+ text := ""
296+ for start := 0; start+len(needle) <= len(hay); {
297+ if !slices.Equal(hay[start:start+len(needle)], needle) {
298+ start++
299+ continue
300+ }
301+ if text == "" {
302+ text = doc.LineText(row)
303+ }
304+ end := start + len(needle)
305+ cellEnd := len(cells)
306+ if end < len(indexes) {
307+ cellEnd = indexes[end]
308+ }
309+ matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})
310+ start += len(needle)
311+ }
312+ return true
313+ })
314+ return matches
270 }315 }
271-316+func searchableCells(cells []renderer.Cell) ([]rune, []int) {
272-func (m *Mode) SelectedBounds() (int, int, bool) {317+ rs := make([]rune, 0, len(cells))
273- if !m.Selecting || m.Anchor < 0 {318+ is := make([]int, 0, len(cells))
274- return 0, 0, false319+ for x, c := range cells {
320+ if c.Continuation {
321+ continue
322+ }
323+ r := c.Rune
324+ if r == 0 {
325+ r = ' '
326+ }
327+ rs = append(rs, unicode.ToLower(r))
328+ is = append(is, x)
275 }329 }
276- lo, hi := m.Anchor, m.Cursor330+ return rs, is
277- if lo > hi {331+}
278- lo, hi = hi, lo332+func lowerRunes(s string) []rune {
333+ rs := []rune(s)
334+ for i, r := range rs {
335+ rs[i] = unicode.ToLower(r)
279 }336 }
280- return lo, hi, true337+ return rs
281 }338 }
282 339
283-func (m *Mode) SelectedText(s Snapshot) string {340+func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
284- lo, hi, ok := m.SelectedBounds()341+ if m == nil || m.document == nil {
285- if !ok || s.Len() == 0 {342+ return renderer.NewFrame(0, 0)
286- return ""
287 }343 }
288- lo = max(lo, 0)344+ d := m.document
289- hi = min(hi, s.Len()-1)345+ frame := renderer.NewFrame(d.Width(), d.Height()+1)
290- lines := make([]string, 0, hi-lo+1)346+ ranges := m.selection.Ranges(d)
291- for i := lo; i <= hi; i++ {347+ selected := make(map[int][]CellRange, len(ranges))
292- lines = append(lines, rowString(s.Row(i)))348+ for _, r := range ranges {
349+ selected[r.Row] = append(selected[r.Row], r)
293 }350 }
294- return strings.Join(lines, "\n")351+ selection, hasSelection := optionalStyle(styles, 1)
295-}352+ for y := range d.Height() {
296-
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 {
303 src := m.ViewportTop + y353 src := m.ViewportTop + y
304- if src >= s.Len() {354+ if src >= d.Len() {
305 break355 break
306 }356 }
307 row := frame.Row(y)357 row := frame.Row(y)
308- copy(row, s.Row(src))358+ copy(row, d.Row(src))
309- lineSelected := selected && src >= lo && src <= hi359+ if match, ok := m.currentSearchMatchForRow(src); ok {
310- if lineSelected {360+ for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
311- for x := range row {361+ applySelectionStyle(&row[x].Style, selection, hasSelection)
312- applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
313 }362 }
314 }363 }
315- if match, ok := m.currentSearchMatchForRow(src); ok {364+ covered := false
316- for x := match.Start; x < match.End && x < len(row); x++ {365+ for _, r := range selected[src] {
317- applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)366+ covered = true
367+ for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
368+ applySelectionStyle(&row[x].Style, selection, hasSelection)
318 }369 }
319 }370 }
320- if src == m.Cursor && !lineSelected && len(row) > 0 {371+ if src == m.navigator.Pos.Row && !covered && len(row) > 0 {
321- applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)372+ col := min(max(m.navigator.Pos.Col, 0), len(row)-1)
373+ applySelectionStyle(&row[col].Style, selection, hasSelection)
322 }374 }
323 }375 }
324- style := inverseStyle()376+ status := inverseStyle()
325 if len(styles) > 0 {377 if len(styles) > 0 {
326- style = styles[0]378+ status = styles[0]
327 }379 }
328- drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)380+ drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status)
329 return frame381 return frame
330 }382 }
331-
332 func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {383 func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
333- if m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {384+ if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
334 return SearchMatch{}, false385 return SearchMatch{}, false
335 }386 }
336- match := m.Searches[m.SearchIndex]387+ v := m.Searches[m.SearchIndex]
337- return match, match.Row == row388+ return v, v.Row == row
338 }389 }
339-
340 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {390 func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
341 if idx >= len(styles) {391 if idx >= len(styles) {
342 return renderer.Style{}, false392 return renderer.Style{}, false
343 }393 }
344 return styles[idx], true394 return styles[idx], true
345 }395 }
346-396+func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
347-func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {397+ if !ok || style.Equal(inverseStyle()) {
348- if !ok || selection.Equal(inverseStyle()) {
349 dst.Inverse = true398 dst.Inverse = true
350 return399 return
351 }400 }
352- *dst = selection401+ *dst = style
353-}
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 }402 }
375-403+func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
376 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {404 func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
377 for i := range row {405 for i := range row {
378 row[i] = renderer.BlankCell()406 row[i] = renderer.BlankCell()
379 }407 }
380 text := " [SCROLL] "408 text := " [SCROLL] "
381- if m.Selecting {409+ if m.selection.Enabled {
382 text = " [SELECT] "410 text = " [SELECT] "
383 }411 }
384 if total > 0 {412 if total > 0 {
385- text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "413+ text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
386 } else {414 } else {
387 text += "0/0 "415 text += "0/0 "
388 }416 }
@@ -400,21 +428,12 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Styl
400 }428 }
401 }429 }
402 430
403-// OSC52 encodes text for clipboard transfer as one complete OSC 52 sequence.
404-// Oversized payloads return no sequence rather than emitting corrupting
405-// multi-sequence replacements.
406 func OSC52(text string) [][]byte {431 func OSC52(text string) [][]byte {
407 if len([]byte(text)) > OSC52MaxPayloadBytes {432 if len([]byte(text)) > OSC52MaxPayloadBytes {
408 return nil433 return nil
409 }434 }
410- encoded := base64.StdEncoding.EncodeToString([]byte(text))435+ return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
411- return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
412 }436 }
413-
414-// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
415-// clipboard set request that a pane app already emitted as base64. It
416-// returns nil if b64 is not valid base64 or its decoded length exceeds
417-// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
418 func OSC52FromBase64(b64 string) []byte {437 func OSC52FromBase64(b64 string) []byte {
419 decoded, err := base64.StdEncoding.DecodeString(b64)438 decoded, err := base64.StdEncoding.DecodeString(b64)
420 if err != nil || len(decoded) > OSC52MaxPayloadBytes {439 if err != nil || len(decoded) > OSC52MaxPayloadBytes {
@@ -422,17 +441,22 @@ func OSC52FromBase64(b64 string) []byte {
422 }441 }
423 return []byte("\x1b]52;c;" + b64 + "\x07")442 return []byte("\x1b]52;c;" + b64 + "\x07")
424 }443 }
425-
426 func strconvItoa(n int) string {444 func strconvItoa(n int) string {
427 if n == 0 {445 if n == 0 {
428 return "0"446 return "0"
429 }447 }
430- var buf [20]byte448+ var b [20]byte
431- i := len(buf)449+ i := len(b)
432 for n > 0 {450 for n > 0 {
433 i--451 i--
434- buf[i] = byte('0' + n%10)452+ b[i] = byte('0' + n%10)
435 n /= 10453 n /= 10
436 }454 }
437- return string(buf[i:])455+ return string(b[i:])
456+}
457+func abs(n int) int {
458+ if n < 0 {
459+ return -n
460+ }
461+ return n
438 }462 }
M internal/usecase/copy/mode_test.go +90 -618
@@ -6,636 +6,108 @@ import (
66 "strings"
77 "testing"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/pkg/renderer"
10- "github.com/bnema/vev/pkg/vt"
1111 "github.com/stretchr/testify/require"
1212 )
1313
14-var benchmarkSnapshotSink Snapshot
15-
1614 func row(text string) []renderer.Cell {
17- result := make([]renderer.Cell, 0, len([]rune(text)))
15+ cells := make([]renderer.Cell, 0, len([]rune(text)))
1816 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
17+ cells = append(cells, renderer.Cell{Rune: r})
4618 }
4719 return cells
4820 }
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
171- }{
172- {
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)
185- },
186- wantCursor: 2,
187- wantTop: 2,
188- wantAnchor: 2,
189- wantSelecting: true,
190- },
191- {
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)
196- },
197- wantCursor: 4,
198- wantTop: 2,
199- wantAnchor: 1,
200- wantSelecting: true,
201- },
202- }
203-
204- for _, tt := range tests {
205- 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- }
212- })
213- }
214-}
215-
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})
380- 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)
451- }
452-
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- }
492-}
493-
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
21+func modeFor(lines []string, height int) *Mode {
22+ rows := make([][]renderer.Cell, len(lines))
23+ for i := range lines {
24+ rows[i] = row(lines[i])
25+ }
26+ return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
27+}
28+
29+func TestCopyModeKeyboardSelection(t *testing.T) {
30+ m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
31+ require.Equal(t, Pos{Row: 3}, m.Cursor())
32+ m.Top()
33+ m.ToggleLineSelection()
34+ m.Down()
35+ require.Equal(t, Line, m.Selection().Granularity)
36+ require.Equal(t, "alpha\nxy", m.SelectedText())
37+ m.Right()
38+ require.Equal(t, Character, m.Selection().Granularity)
39+ require.Equal(t, Pos{Row: 0}, m.Selection().Anchor)
40+ require.Equal(t, Pos{Row: 1, Col: 1}, m.Selection().Active)
41+ require.True(t, m.Left())
42+ require.Equal(t, Pos{Row: 1}, m.Cursor())
43+ require.True(t, m.Right())
44+ require.True(t, m.Up())
45+ require.Equal(t, Pos{Row: 0, Col: 1}, m.Cursor())
46+ require.True(t, m.Down())
47+ require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
48+ require.True(t, m.WordNext())
49+ require.Equal(t, Pos{Row: 2}, m.Cursor())
50+ require.True(t, m.WordEnd())
51+ require.Equal(t, Pos{Row: 2, Col: 4}, m.Cursor())
52+ require.True(t, m.WordBackward())
53+ require.Equal(t, Pos{Row: 2}, m.Cursor())
54+}
55+
56+func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
57+ m := modeFor([]string{"abcdef", "xy", "abcdef"}, 2)
58+ require.True(t, m.SetPosition(Pos{Row: 0, Col: 5}))
59+ m.Down()
60+ require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
61+ m.Down()
62+ require.Equal(t, Pos{Row: 2, Col: 5}, m.Cursor())
63+ m.SetPosition(Pos{Row: 0, Col: 1})
64+ require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 1}))
65+ require.True(t, m.ExtendCharacterSelection(Pos{Row: 0, Col: 4}))
66+ require.Equal(t, "bcde", m.SelectedText())
67+}
68+
69+func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
70+ cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
71+ m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
72+ require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
73+ require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 0}))
74+ f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})
75+ require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
76+ require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
77+}
78+
79+func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
80+ cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
81+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
82+ require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
83+}
84+
85+func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
86+ cases := []struct {
87+ name string
88+ selectFn func(*Mode)
89+ want string
50290 }{
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- }
91+ {"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"},
92+ {"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"},
93+ {"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"},
94+ {"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"},
95+ {"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"},
96+ }
97+ for _, tc := range cases {
98+ t.Run(tc.name, func(t *testing.T) {
99+ m := modeFor([]string{"alpha", "bravo"}, 2)
100+ tc.selectFn(m)
101+ got := OSC52(m.SelectedText())
102+ require.Len(t, got, 1)
103+ want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07")
104+ require.True(t, bytes.Equal(want, got[0]))
514105 })
515106 }
516107 }
517108
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")
559- }
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-}
566-
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- }
581- })
582- return result.AllocedBytesPerOp()
583-}
584-
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) {
607- const (
608- width = 120
609- height = 40
610- historyRows = 10_000
611- )
612-
613- history := newHistory(historyRows)
614- for range historyRows {
615- history.Append(row(strings.Repeat("x", width)))
616- }
617- screen := renderer.NewFrame(width, height)
618- for y := range height {
619- copy(screen.Row(y), row(strings.Repeat("s", width)))
620- }
621-
622- b.ReportAllocs()
623- b.ResetTimer()
624- for b.Loop() {
625- benchmarkSnapshotSink = NewSnapshot(history, screen)
626- }
627-}
628-
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)
639- }
640- return decoded
109+func TestOSC52LimitsAndBase64(t *testing.T) {
110+ require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
111+ encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
112+ require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
641113 }
@@ -6,636 +6,108 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10- "github.com/bnema/vev/pkg/vt"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 )12 )
13 13
14-var benchmarkSnapshotSink Snapshot
15-
16 func row(text string) []renderer.Cell {14 func row(text string) []renderer.Cell {
17- result := make([]renderer.Cell, 0, len([]rune(text)))15+ cells := make([]renderer.Cell, 0, len([]rune(text)))
18 for _, r := range text {16 for _, r := range text {
19- result = append(result, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})17+ cells = append(cells, renderer.Cell{Rune: r})
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
46 }18 }
47 return cells19 return cells
48 }20 }
49-21+func modeFor(lines []string, height int) *Mode {
50-func TestCopyModeNavigationBounds(t *testing.T) {22+ rows := make([][]renderer.Cell, len(lines))
51- tests := []struct {23+ for i := range lines {
52- name string24+ rows[i] = row(lines[i])
53- lines []string25+ }
54- height int26+ return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
55- ops func(*Mode, Snapshot)27+}
56- wantCursor int28+
57- wantTop int29+func TestCopyModeKeyboardSelection(t *testing.T) {
58- }{30+ m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
59- {31+ require.Equal(t, Pos{Row: 3}, m.Cursor())
60- name: "initial viewport starts at bottom",32+ m.Top()
61- lines: []string{"00", "01", "02", "03", "04"},33+ m.ToggleLineSelection()
62- height: 3,34+ m.Down()
63- ops: func(*Mode, Snapshot) {},35+ require.Equal(t, Line, m.Selection().Granularity)
64- wantCursor: 4,36+ require.Equal(t, "alpha\nxy", m.SelectedText())
65- wantTop: 2,37+ m.Right()
66- },38+ require.Equal(t, Character, m.Selection().Granularity)
67- {39+ require.Equal(t, Pos{Row: 0}, m.Selection().Anchor)
68- name: "k clamps at top and scrolls viewport",40+ require.Equal(t, Pos{Row: 1, Col: 1}, m.Selection().Active)
69- lines: []string{"00", "01", "02", "03", "04"},41+ require.True(t, m.Left())
70- height: 3,42+ require.Equal(t, Pos{Row: 1}, m.Cursor())
71- ops: func(m *Mode, s Snapshot) {43+ require.True(t, m.Right())
72- m.Move(s, -99)44+ require.True(t, m.Up())
73- },45+ require.Equal(t, Pos{Row: 0, Col: 1}, m.Cursor())
74- wantCursor: 0,46+ require.True(t, m.Down())
75- wantTop: 0,47+ require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
76- },48+ require.True(t, m.WordNext())
77- {49+ require.Equal(t, Pos{Row: 2}, m.Cursor())
78- name: "j clamps at bottom",50+ require.True(t, m.WordEnd())
79- lines: []string{"00", "01", "02", "03", "04"},51+ require.Equal(t, Pos{Row: 2, Col: 4}, m.Cursor())
80- height: 3,52+ require.True(t, m.WordBackward())
81- ops: func(m *Mode, s Snapshot) {53+ require.Equal(t, Pos{Row: 2}, m.Cursor())
82- m.Move(s, 99)54+}
83- },55+
84- wantCursor: 4,56+func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
85- wantTop: 2,57+ m := modeFor([]string{"abcdef", "xy", "abcdef"}, 2)
86- },58+ require.True(t, m.SetPosition(Pos{Row: 0, Col: 5}))
87- {59+ m.Down()
88- name: "page up moves by viewport height",60+ require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
89- lines: []string{"00", "01", "02", "03", "04", "05", "06"},61+ m.Down()
90- height: 3,62+ require.Equal(t, Pos{Row: 2, Col: 5}, m.Cursor())
91- ops: func(m *Mode, s Snapshot) {63+ m.SetPosition(Pos{Row: 0, Col: 1})
92- m.Page(s, -1)64+ require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 1}))
93- },65+ require.True(t, m.ExtendCharacterSelection(Pos{Row: 0, Col: 4}))
94- wantCursor: 3,66+ require.Equal(t, "bcde", m.SelectedText())
95- wantTop: 3,67+}
96- },68+
97- {69+func TestCopyModeRenderSelectionWinsSearchAndWideGlyph(t *testing.T) {
98- name: "g and G jump to document bounds",70+ cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
99- lines: []string{"00", "01", "02", "03", "04"},71+ m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
100- height: 3,72+ require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
101- ops: func(m *Mode, s Snapshot) {73+ require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 0}))
102- m.Top(s)74+ f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})
103- m.Bottom(s)75+ require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
104- },76+ require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
105- wantCursor: 4,77+}
106- wantTop: 2,78+
107- },79+func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
108- }80+ cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
109-81+ doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
110- for _, tt := range tests {82+ require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
111- t.Run(tt.name, func(t *testing.T) {83+}
112- s := snapshot(tt.lines, tt.height)84+
113- m := NewMode(s)85+func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
114- tt.ops(m, s)86+ cases := []struct {
115- if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop {87+ name string
116- t.Fatalf("cursor/top = %d/%d, want %d/%d", m.Cursor, m.ViewportTop, tt.wantCursor, tt.wantTop)88+ selectFn func(*Mode)
117- }89+ want string
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
171- }{
172- {
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)
185- },
186- wantCursor: 2,
187- wantTop: 2,
188- wantAnchor: 2,
189- wantSelecting: true,
190- },
191- {
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)
196- },
197- wantCursor: 4,
198- wantTop: 2,
199- wantAnchor: 1,
200- wantSelecting: true,
201- },
202- }
203-
204- for _, tt := range tests {
205- 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- }
212- })
213- }
214-}
215-
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})
380- 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)
451- }
452-
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- }
492-}
493-
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 }{90 }{
503- {name: "valid", in: valid, want: []byte("\x1b]52;c;" + valid + "\x07")},91+ {"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"},
504- {name: "empty payload", in: "", want: []byte("\x1b]52;c;\x07")},92+ {"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"},
505- {name: "invalid base64", in: "not-valid-base64!!", want: nil},93+ {"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"},
506- {name: "oversized decoded payload", in: oversized, want: nil},94+ {"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"},
507- }95+ {"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"},
508- for _, tt := range tests {96+ }
509- t.Run(tt.name, func(t *testing.T) {97+ for _, tc := range cases {
510- got := OSC52FromBase64(tt.in)98+ t.Run(tc.name, func(t *testing.T) {
511- if !bytes.Equal(got, tt.want) {99+ m := modeFor([]string{"alpha", "bravo"}, 2)
512- t.Fatalf("OSC52FromBase64(%q) = %q, want %q", tt.in, got, tt.want)100+ tc.selectFn(m)
513- }101+ got := OSC52(m.SelectedText())
102+ require.Len(t, got, 1)
103+ want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07")
104+ require.True(t, bytes.Equal(want, got[0]))
514 })105 })
515 }106 }
516 }107 }
517 108
518-func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {109+func TestOSC52LimitsAndBase64(t *testing.T) {
519- history := newHistory(1)110+ require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
520- history.Append(row("old"))111+ encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
521- screen := renderer.NewFrame(3, 1)112+ require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
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")
559- }
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-}
566-
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- }
581- })
582- return result.AllocedBytesPerOp()
583-}
584-
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) {
607- const (
608- width = 120
609- height = 40
610- historyRows = 10_000
611- )
612-
613- history := newHistory(historyRows)
614- for range historyRows {
615- history.Append(row(strings.Repeat("x", width)))
616- }
617- screen := renderer.NewFrame(width, height)
618- for y := range height {
619- copy(screen.Row(y), row(strings.Repeat("s", width)))
620- }
621-
622- b.ReportAllocs()
623- b.ResetTimer()
624- for b.Loop() {
625- benchmarkSnapshotSink = NewSnapshot(history, screen)
626- }
627-}
628-
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)
639- }
640- return decoded
641 }113 }
M internal/usecase/daemon/copymode.go +94 -129
@@ -31,27 +31,24 @@ func copyTargetPane(rt *overlayRuntime) *pane {
3131 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
3232 rt := ac.overlays
3333 rt.copyMu.Lock()
34- if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
34+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
3535 rt.copyMu.Unlock()
3636 return
3737 }
38- document := rt.copySnapshot
39- if delta > 0 && rt.copyMode.AtBottom(*document) {
38+ if delta > 0 && rt.copyMode.AtBottom() {
4039 rt.clearCopyModeLocked()
4140 rt.copyMu.Unlock()
4241 d.invalidateRender(sess, ac, true, "copymode.go")
4342 return
4443 }
45- rt.copyMode.Move(*document, delta)
46- exit := delta > 0 && rt.copyMode.AtBottom(*document)
44+ rt.copyMode.MoveRows(delta)
45+ exit := delta > 0 && rt.copyMode.AtBottom()
4746 if exit {
4847 rt.clearCopyModeLocked()
4948 }
5049 rt.copyMu.Unlock()
51-
5250 d.invalidateRender(sess, ac, true, "copymode.go")
5351 }
54-
5552 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
5653 tb := sess.activeTab()
5754 if tb == nil {
@@ -63,23 +60,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
6360 if p == nil {
6461 return
6562 }
66- // Capture the live pane under pane.mu; all subsequent copy interaction uses
67- // the immutable document while holding only copyMu.
6863 p.mu.Lock()
69- document := scopy.NewSnapshot(p.history, p.screen.Frame)
64+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
7065 p.mu.Unlock()
71- if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
66+ cfg := d.currentCopyConfig()
67+ document := scopy.NewDocument(snapshot, cfg.WordSeparators)
68+ if !d.publishCopyMode(sess, ac, tb, p, document, nil, nil) {
7269 return
7370 }
7471 d.invalidateRender(sess, ac, true, "copymode.go")
7572 }
7673
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 {
74+// publishCopyMode serializes overlay publication after pane membership is
75+// revalidated. Document is the sole immutable copy payload.
76+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 {
77+ if sess == nil || ac == nil || tb == nil || p == nil || document == nil {
8378 return false
8479 }
8580 mode := scopy.NewMode(document)
@@ -92,7 +87,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
9287 rt.copyPending = nil
9388 rt.copyMode = nil
9489 rt.copyCandidate = mode
95- rt.copySnapshot = &document
90+ rt.copyDocument = document
9691 rt.copyPane = p
9792 rt.copySearch = nil
9893 rt.copySearchPending = nil
@@ -100,18 +95,15 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
10095 rt.copyDragging = false
10196 rt.normalMousePressValid = false
10297 rt.copyMu.Unlock()
103-
104- active := sess.activeTab()
105- valid := active == tb
98+ valid := sess.activeTab() == tb
10699 if valid {
107100 tb.mu.Lock()
108101 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
109102 tb.mu.Unlock()
110103 }
111-
112104 rt.copyMu.Lock()
113105 defer rt.copyMu.Unlock()
114- if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document {
106+ if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {
115107 return false
116108 }
117109 if !valid {
@@ -120,21 +112,25 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
120112 }
121113 rt.copyCandidate = nil
122114 rt.copyMode = mode
115+ if activate != nil {
116+ activate(rt, mode)
117+ }
123118 return true
124119 }
125120
121+// copyMouse remains row-compatible until centralized pointer mapping supplies columns.
126122 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
127123 rt := ac.overlays
128124 if ev.Button != mouse.Left {
129125 return
130126 }
131127 rt.copyMu.Lock()
132- if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
128+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
133129 rt.copyMu.Unlock()
134130 return
135131 }
136- document := rt.copySnapshot
137- if ev.Row >= document.Height {
132+ document := rt.copyDocument
133+ if ev.Row >= document.Height() {
138134 if ev.Type == mouse.Press {
139135 rt.copyPressRowValid = false
140136 rt.copyDragging = false
@@ -142,30 +138,30 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
142138 rt.copyMu.Unlock()
143139 return
144140 }
145- absRow := rt.copyMode.ViewportTop + ev.Row
141+ pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0}
146142 changed := false
147143 switch ev.Type {
148144 case mouse.Press:
149- rt.copyMode.SetCursor(*document, absRow)
150- rt.copyPressRow = rt.copyMode.Cursor
145+ if rt.copyMode.SetPosition(pos) {
146+ changed = true
147+ }
148+ rt.copyPressRow = rt.copyMode.Cursor().Row
151149 rt.copyPressRowValid = true
152150 rt.copyDragging = false
153- changed = true
154151 case mouse.Motion:
155- if !rt.copyPressRowValid {
156- break
157- }
158- if !rt.copyDragging {
159- rt.copyMode.StartSelectionAt(*document, rt.copyPressRow)
160- rt.copyDragging = true
152+ if rt.copyPressRowValid {
153+ if !rt.copyDragging {
154+ rt.copyMode.SetPosition(scopy.Pos{Row: rt.copyPressRow, Col: 0})
155+ rt.copyMode.ToggleLineSelection()
156+ rt.copyDragging = true
157+ }
158+ changed = rt.copyMode.MoveRows(pos.Row-rt.copyMode.Cursor().Row) || changed
161159 }
162- rt.copyMode.ExtendTo(*document, absRow)
163- changed = true
164160 case mouse.Release:
165- // Button release intentionally has no visual effect.
161+ rt.copyPressRowValid = false
162+ rt.copyDragging = false
166163 }
167164 rt.copyMu.Unlock()
168-
169165 if changed {
170166 d.invalidateRender(sess, ac, true, "copymode.go")
171167 }
@@ -178,7 +174,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
178174 return
179175 }
180176 rt.copyMu.Lock()
181- if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
177+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
182178 rt.copyPending = nil
183179 d.stopCopyPendingTimerLocked(ac)
184180 rt.copyMu.Unlock()
@@ -186,61 +182,71 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
186182 }
187183 if len(rt.copyPending) > 0 {
188184 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
185+ data = append(append([]byte(nil), rt.copyPending...), data...)
193186 rt.copyPending = nil
194187 }
195- document := rt.copySnapshot
196188 if rt.copySearch != nil {
197- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
189+ changed, close, accepted := d.routeCopySearchInputLocked(rt, data)
198190 rt.copyMu.Unlock()
199- if changed || closeSearch || accepted {
191+ if changed || close || accepted {
200192 d.invalidateRender(sess, ac, true, "copymode.go")
201193 }
202194 return
203195 }
204- changed := false
205- copyOut := false
206- exit := false
196+ changed, copyOut, exit := false, false, false
207197 for i := 0; i < len(data); i++ {
208198 switch data[i] {
199+ case 'h':
200+ rt.copyMode.Left()
201+ changed = true
209202 case 'j':
210- rt.copyMode.Move(*document, 1)
203+ rt.copyMode.Down()
211204 changed = true
212205 case 'k':
213- rt.copyMode.Move(*document, -1)
206+ rt.copyMode.Up()
207+ changed = true
208+ case 'l':
209+ rt.copyMode.Right()
210+ changed = true
211+ case 'w':
212+ rt.copyMode.WordNext()
213+ changed = true
214+ case 'b':
215+ rt.copyMode.WordBackward()
216+ changed = true
217+ case 'e':
218+ rt.copyMode.WordEnd()
214219 changed = true
215220 case 'g':
216- rt.copyMode.Top(*document)
221+ rt.copyMode.Top()
217222 changed = true
218223 case 'G':
219- rt.copyMode.Bottom(*document)
224+ rt.copyMode.Bottom()
220225 changed = true
221226 case ' ', 'v':
222- rt.copyMode.ToggleSelection()
227+ rt.copyMode.ToggleLineSelection()
223228 changed = true
224229 case '/':
225- rt.copySearch = visualsearch.New(*document)
230+ rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot())
226231 rt.copySearchPending = nil
227232 changed = true
228233 if i+1 < len(data) {
229- searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:])
230- changed = changed || searchChanged || accepted
234+ a, b, c := d.routeCopySearchInputLocked(rt, data[i+1:])
235+ changed = changed || a || c
236+ _ = b
231237 }
232238 i = len(data)
233239 case 'n':
234- changed = rt.copyMode.NextSearchMatch(*document, 1) || changed
240+ changed = rt.copyMode.NextSearchMatch(1) || changed
235241 case 'N':
236- changed = rt.copyMode.NextSearchMatch(*document, -1) || changed
242+ changed = rt.copyMode.NextSearchMatch(-1) || changed
237243 case '\r', '\n', 'y':
238244 copyOut = true
239245 exit = true
240246 case 'q', 0x03, 0x1b:
241247 if data[i] == 0x1b {
242248 tail := data[i:]
243- consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)
249+ consumed, ok := routeCopyEscape(rt.copyMode, tail)
244250 if ok {
245251 i += consumed - 1
246252 changed = true
@@ -260,14 +266,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
260266 }
261267 text := ""
262268 if copyOut {
263- text = rt.copyMode.SelectedText(*document)
269+ text = rt.copyMode.SelectedText()
264270 }
265271 if exit {
266272 d.stopCopyPendingTimerLocked(ac)
267273 rt.clearCopyModeLocked()
268274 }
269275 rt.copyMu.Unlock()
270-
271276 if copyOut && text != "" {
272277 chunks := scopy.OSC52(text)
273278 for _, chunk := range chunks {
@@ -285,63 +290,34 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
285290 }
286291 rt.copyMu.Unlock()
287292 }
288- if exit {
289- d.invalidateRender(sess, ac, true, "copymode.go")
290- return
291- }
292- if changed {
293+ if exit || changed {
293294 d.invalidateRender(sess, ac, true, "copymode.go")
294295 }
295296 }
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- })
297+func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {
298+ routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {
299+ if _, ok := rt.copySearch.Selected(); ok {
300+ accepted = rt.copyMode.SetSearchMatches(rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
301+ closeSearch = accepted
302+ }
303+ }, cancel: func() { closeSearch = true }, up: func() { rt.copySearch.Up(); changed = true }, down: func() { rt.copySearch.Down(); changed = true }})
324304 if changed && !closeSearch {
325- d.previewCopySearchSelectionLocked(rt, snap)
305+ d.previewCopySearchSelectionLocked(rt)
326306 }
327307 if closeSearch {
328308 rt.copySearch = nil
329309 rt.copySearchPending = nil
330310 }
331- return changed, closeSearch, accepted
311+ return
332312 }
333-
334-func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
313+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
335314 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
336315 return
337316 }
338- match, ok := rt.copySearch.Selected()
339- if !ok {
340- return
317+ if match, ok := rt.copySearch.Selected(); ok {
318+ rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})
341319 }
342- rt.copyMode.SetCursor(snap, match.Row)
343320 }
344-
345321 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
346322 rt := ac.overlays
347323 mode := rt.copyMode
@@ -361,53 +337,44 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
361337 }
362338 rt.clearCopyModeLocked()
363339 rt.copyMu.Unlock()
364-
365340 if sess := ac.currentSession(); sess != nil {
366341 d.invalidateRender(sess, ac, true, "copymode.go")
367342 }
368343 })
369344 }
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) {
345+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }
346+func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
377347 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
378348 switch data[2] {
379349 case 'A':
380- m.Move(snap, -1)
350+ m.Up()
381351 return 3, true
382352 case 'B':
383- m.Move(snap, 1)
353+ m.Down()
384354 return 3, true
385355 }
386356 }
387357 if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
388358 switch data[2] {
389359 case '5':
390- m.Page(snap, -1)
360+ m.Page(-1)
391361 return 4, true
392362 case '6':
393- m.Page(snap, 1)
363+ m.Page(1)
394364 return 4, true
395365 }
396366 }
397367 return 0, false
398368 }
399-
400369 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')
370+ 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')
403371 }
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 {
372+func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
373+ if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
407374 return frame, nil
408375 }
409376 styles := newThemeStyles(bars.theme)
410- copyFrame := mode.Render(*document, styles.copyStatus, styles.selection)
377+ copyFrame := mode.Render(styles.copyStatus, styles.selection)
411378 bodyRows := max(copyFrame.Height-1, 0)
412379 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
413380 dstX := max(target.X, 0)
@@ -419,10 +386,8 @@ func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target d
419386 }
420387 }
421388 statusY := frame.Height - 1
422- statusRow := frame.Row(statusY)
423- blank := renderer.BlankCell()
424- for x := range statusRow {
425- statusRow[x] = blank
389+ for x := range frame.Row(statusY) {
390+ frame.Row(statusY)[x] = renderer.BlankCell()
426391 }
427392 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
428393 return frame, []renderer.Damage{renderer.FullRedraw()}
@@ -31,27 +31,24 @@ func copyTargetPane(rt *overlayRuntime) *pane {
31 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {31 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
32 rt := ac.overlays32 rt := ac.overlays
33 rt.copyMu.Lock()33 rt.copyMu.Lock()
34- if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {34+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
35 rt.copyMu.Unlock()35 rt.copyMu.Unlock()
36 return36 return
37 }37 }
38- document := rt.copySnapshot38+ if delta > 0 && rt.copyMode.AtBottom() {
39- if delta > 0 && rt.copyMode.AtBottom(*document) {
40 rt.clearCopyModeLocked()39 rt.clearCopyModeLocked()
41 rt.copyMu.Unlock()40 rt.copyMu.Unlock()
42 d.invalidateRender(sess, ac, true, "copymode.go")41 d.invalidateRender(sess, ac, true, "copymode.go")
43 return42 return
44 }43 }
45- rt.copyMode.Move(*document, delta)44+ rt.copyMode.MoveRows(delta)
46- exit := delta > 0 && rt.copyMode.AtBottom(*document)45+ exit := delta > 0 && rt.copyMode.AtBottom()
47 if exit {46 if exit {
48 rt.clearCopyModeLocked()47 rt.clearCopyModeLocked()
49 }48 }
50 rt.copyMu.Unlock()49 rt.copyMu.Unlock()
51-
52 d.invalidateRender(sess, ac, true, "copymode.go")50 d.invalidateRender(sess, ac, true, "copymode.go")
53 }51 }
54-
55 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {52 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
56 tb := sess.activeTab()53 tb := sess.activeTab()
57 if tb == nil {54 if tb == nil {
@@ -63,23 +60,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
63 if p == nil {60 if p == nil {
64 return61 return
65 }62 }
66- // Capture the live pane under pane.mu; all subsequent copy interaction uses
67- // the immutable document while holding only copyMu.
68 p.mu.Lock()63 p.mu.Lock()
69- document := scopy.NewSnapshot(p.history, p.screen.Frame)64+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
70 p.mu.Unlock()65 p.mu.Unlock()
71- if !d.publishCopyMode(sess, ac, tb, p, document, nil) {66+ cfg := d.currentCopyConfig()
67+ document := scopy.NewDocument(snapshot, cfg.WordSeparators)
68+ if !d.publishCopyMode(sess, ac, tb, p, document, nil, nil) {
72 return69 return
73 }70 }
74 d.invalidateRender(sess, ac, true, "copymode.go")71 d.invalidateRender(sess, ac, true, "copymode.go")
75 }72 }
76 73
77-// publishCopyMode installs a non-renderable candidate, validates its captured74+// publishCopyMode serializes overlay publication after pane membership is
78-// target without overlapping copyMu with session or tab locks, then atomically75+// revalidated. Document is the sole immutable copy payload.
79-// activates that same candidate. A tab transition, pane close, or newer76+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 {
80-// publication invalidates the candidate before it can render or be yanked.77+ if sess == nil || ac == nil || tb == nil || p == nil || document == nil {
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 {
83 return false78 return false
84 }79 }
85 mode := scopy.NewMode(document)80 mode := scopy.NewMode(document)
@@ -92,7 +87,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
92 rt.copyPending = nil87 rt.copyPending = nil
93 rt.copyMode = nil88 rt.copyMode = nil
94 rt.copyCandidate = mode89 rt.copyCandidate = mode
95- rt.copySnapshot = &document90+ rt.copyDocument = document
96 rt.copyPane = p91 rt.copyPane = p
97 rt.copySearch = nil92 rt.copySearch = nil
98 rt.copySearchPending = nil93 rt.copySearchPending = nil
@@ -100,18 +95,15 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
100 rt.copyDragging = false95 rt.copyDragging = false
101 rt.normalMousePressValid = false96 rt.normalMousePressValid = false
102 rt.copyMu.Unlock()97 rt.copyMu.Unlock()
103-98+ valid := sess.activeTab() == tb
104- active := sess.activeTab()
105- valid := active == tb
106 if valid {99 if valid {
107 tb.mu.Lock()100 tb.mu.Lock()
108 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)101 valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
109 tb.mu.Unlock()102 tb.mu.Unlock()
110 }103 }
111-
112 rt.copyMu.Lock()104 rt.copyMu.Lock()
113 defer rt.copyMu.Unlock()105 defer rt.copyMu.Unlock()
114- if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document {106+ if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {
115 return false107 return false
116 }108 }
117 if !valid {109 if !valid {
@@ -120,21 +112,25 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
120 }112 }
121 rt.copyCandidate = nil113 rt.copyCandidate = nil
122 rt.copyMode = mode114 rt.copyMode = mode
115+ if activate != nil {
116+ activate(rt, mode)
117+ }
123 return true118 return true
124 }119 }
125 120
121+// copyMouse remains row-compatible until centralized pointer mapping supplies columns.
126 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {122 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
127 rt := ac.overlays123 rt := ac.overlays
128 if ev.Button != mouse.Left {124 if ev.Button != mouse.Left {
129 return125 return
130 }126 }
131 rt.copyMu.Lock()127 rt.copyMu.Lock()
132- if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {128+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
133 rt.copyMu.Unlock()129 rt.copyMu.Unlock()
134 return130 return
135 }131 }
136- document := rt.copySnapshot132+ document := rt.copyDocument
137- if ev.Row >= document.Height {133+ if ev.Row >= document.Height() {
138 if ev.Type == mouse.Press {134 if ev.Type == mouse.Press {
139 rt.copyPressRowValid = false135 rt.copyPressRowValid = false
140 rt.copyDragging = false136 rt.copyDragging = false
@@ -142,30 +138,30 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
142 rt.copyMu.Unlock()138 rt.copyMu.Unlock()
143 return139 return
144 }140 }
145- absRow := rt.copyMode.ViewportTop + ev.Row141+ pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0}
146 changed := false142 changed := false
147 switch ev.Type {143 switch ev.Type {
148 case mouse.Press:144 case mouse.Press:
149- rt.copyMode.SetCursor(*document, absRow)145+ if rt.copyMode.SetPosition(pos) {
150- rt.copyPressRow = rt.copyMode.Cursor146+ changed = true
147+ }
148+ rt.copyPressRow = rt.copyMode.Cursor().Row
151 rt.copyPressRowValid = true149 rt.copyPressRowValid = true
152 rt.copyDragging = false150 rt.copyDragging = false
153- changed = true
154 case mouse.Motion:151 case mouse.Motion:
155- if !rt.copyPressRowValid {152+ if rt.copyPressRowValid {
156- break153+ if !rt.copyDragging {
157- }154+ rt.copyMode.SetPosition(scopy.Pos{Row: rt.copyPressRow, Col: 0})
158- if !rt.copyDragging {155+ rt.copyMode.ToggleLineSelection()
159- rt.copyMode.StartSelectionAt(*document, rt.copyPressRow)156+ rt.copyDragging = true
160- rt.copyDragging = true157+ }
158+ changed = rt.copyMode.MoveRows(pos.Row-rt.copyMode.Cursor().Row) || changed
161 }159 }
162- rt.copyMode.ExtendTo(*document, absRow)
163- changed = true
164 case mouse.Release:160 case mouse.Release:
165- // Button release intentionally has no visual effect.161+ rt.copyPressRowValid = false
162+ rt.copyDragging = false
166 }163 }
167 rt.copyMu.Unlock()164 rt.copyMu.Unlock()
168-
169 if changed {165 if changed {
170 d.invalidateRender(sess, ac, true, "copymode.go")166 d.invalidateRender(sess, ac, true, "copymode.go")
171 }167 }
@@ -178,7 +174,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
178 return174 return
179 }175 }
180 rt.copyMu.Lock()176 rt.copyMu.Lock()
181- if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {177+ if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
182 rt.copyPending = nil178 rt.copyPending = nil
183 d.stopCopyPendingTimerLocked(ac)179 d.stopCopyPendingTimerLocked(ac)
184 rt.copyMu.Unlock()180 rt.copyMu.Unlock()
@@ -186,61 +182,71 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
186 }182 }
187 if len(rt.copyPending) > 0 {183 if len(rt.copyPending) > 0 {
188 d.stopCopyPendingTimerLocked(ac)184 d.stopCopyPendingTimerLocked(ac)
189- combined := make([]byte, 0, len(rt.copyPending)+len(data))185+ data = append(append([]byte(nil), rt.copyPending...), data...)
190- combined = append(combined, rt.copyPending...)
191- combined = append(combined, data...)
192- data = combined
193 rt.copyPending = nil186 rt.copyPending = nil
194 }187 }
195- document := rt.copySnapshot
196 if rt.copySearch != nil {188 if rt.copySearch != nil {
197- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)189+ changed, close, accepted := d.routeCopySearchInputLocked(rt, data)
198 rt.copyMu.Unlock()190 rt.copyMu.Unlock()
199- if changed || closeSearch || accepted {191+ if changed || close || accepted {
200 d.invalidateRender(sess, ac, true, "copymode.go")192 d.invalidateRender(sess, ac, true, "copymode.go")
201 }193 }
202 return194 return
203 }195 }
204- changed := false196+ changed, copyOut, exit := false, false, false
205- copyOut := false
206- exit := false
207 for i := 0; i < len(data); i++ {197 for i := 0; i < len(data); i++ {
208 switch data[i] {198 switch data[i] {
199+ case 'h':
200+ rt.copyMode.Left()
201+ changed = true
209 case 'j':202 case 'j':
210- rt.copyMode.Move(*document, 1)203+ rt.copyMode.Down()
211 changed = true204 changed = true
212 case 'k':205 case 'k':
213- rt.copyMode.Move(*document, -1)206+ rt.copyMode.Up()
207+ changed = true
208+ case 'l':
209+ rt.copyMode.Right()
210+ changed = true
211+ case 'w':
212+ rt.copyMode.WordNext()
213+ changed = true
214+ case 'b':
215+ rt.copyMode.WordBackward()
216+ changed = true
217+ case 'e':
218+ rt.copyMode.WordEnd()
214 changed = true219 changed = true
215 case 'g':220 case 'g':
216- rt.copyMode.Top(*document)221+ rt.copyMode.Top()
217 changed = true222 changed = true
218 case 'G':223 case 'G':
219- rt.copyMode.Bottom(*document)224+ rt.copyMode.Bottom()
220 changed = true225 changed = true
221 case ' ', 'v':226 case ' ', 'v':
222- rt.copyMode.ToggleSelection()227+ rt.copyMode.ToggleLineSelection()
223 changed = true228 changed = true
224 case '/':229 case '/':
225- rt.copySearch = visualsearch.New(*document)230+ rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot())
226 rt.copySearchPending = nil231 rt.copySearchPending = nil
227 changed = true232 changed = true
228 if i+1 < len(data) {233 if i+1 < len(data) {
229- searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:])234+ a, b, c := d.routeCopySearchInputLocked(rt, data[i+1:])
230- changed = changed || searchChanged || accepted235+ changed = changed || a || c
236+ _ = b
231 }237 }
232 i = len(data)238 i = len(data)
233 case 'n':239 case 'n':
234- changed = rt.copyMode.NextSearchMatch(*document, 1) || changed240+ changed = rt.copyMode.NextSearchMatch(1) || changed
235 case 'N':241 case 'N':
236- changed = rt.copyMode.NextSearchMatch(*document, -1) || changed242+ changed = rt.copyMode.NextSearchMatch(-1) || changed
237 case '\r', '\n', 'y':243 case '\r', '\n', 'y':
238 copyOut = true244 copyOut = true
239 exit = true245 exit = true
240 case 'q', 0x03, 0x1b:246 case 'q', 0x03, 0x1b:
241 if data[i] == 0x1b {247 if data[i] == 0x1b {
242 tail := data[i:]248 tail := data[i:]
243- consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)249+ consumed, ok := routeCopyEscape(rt.copyMode, tail)
244 if ok {250 if ok {
245 i += consumed - 1251 i += consumed - 1
246 changed = true252 changed = true
@@ -260,14 +266,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
260 }266 }
261 text := ""267 text := ""
262 if copyOut {268 if copyOut {
263- text = rt.copyMode.SelectedText(*document)269+ text = rt.copyMode.SelectedText()
264 }270 }
265 if exit {271 if exit {
266 d.stopCopyPendingTimerLocked(ac)272 d.stopCopyPendingTimerLocked(ac)
267 rt.clearCopyModeLocked()273 rt.clearCopyModeLocked()
268 }274 }
269 rt.copyMu.Unlock()275 rt.copyMu.Unlock()
270-
271 if copyOut && text != "" {276 if copyOut && text != "" {
272 chunks := scopy.OSC52(text)277 chunks := scopy.OSC52(text)
273 for _, chunk := range chunks {278 for _, chunk := range chunks {
@@ -285,63 +290,34 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
285 }290 }
286 rt.copyMu.Unlock()291 rt.copyMu.Unlock()
287 }292 }
288- if exit {293+ if exit || changed {
289- d.invalidateRender(sess, ac, true, "copymode.go")
290- return
291- }
292- if changed {
293 d.invalidateRender(sess, ac, true, "copymode.go")294 d.invalidateRender(sess, ac, true, "copymode.go")
294 }295 }
295 }296 }
296-297+func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {
297-func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, snap scopy.Snapshot, data []byte) (changed bool, closeSearch bool, accepted bool) {298+ routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {
298- routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{299+ if _, ok := rt.copySearch.Selected(); ok {
299- rune: func(r rune) {300+ accepted = rt.copyMode.SetSearchMatches(rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
300- rt.copySearch.Insert(r)301+ closeSearch = accepted
301- changed = true302+ }
302- },303+ }, cancel: func() { closeSearch = true }, up: func() { rt.copySearch.Up(); changed = true }, down: func() { rt.copySearch.Down(); changed = true }})
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- })
324 if changed && !closeSearch {304 if changed && !closeSearch {
325- d.previewCopySearchSelectionLocked(rt, snap)305+ d.previewCopySearchSelectionLocked(rt)
326 }306 }
327 if closeSearch {307 if closeSearch {
328 rt.copySearch = nil308 rt.copySearch = nil
329 rt.copySearchPending = nil309 rt.copySearchPending = nil
330 }310 }
331- return changed, closeSearch, accepted311+ return
332 }312 }
333-313+func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
334-func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) {
335 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {314 if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
336 return315 return
337 }316 }
338- match, ok := rt.copySearch.Selected()317+ if match, ok := rt.copySearch.Selected(); ok {
339- if !ok {318+ rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})
340- return
341 }319 }
342- rt.copyMode.SetCursor(snap, match.Row)
343 }320 }
344-
345 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {321 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
346 rt := ac.overlays322 rt := ac.overlays
347 mode := rt.copyMode323 mode := rt.copyMode
@@ -361,53 +337,44 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
361 }337 }
362 rt.clearCopyModeLocked()338 rt.clearCopyModeLocked()
363 rt.copyMu.Unlock()339 rt.copyMu.Unlock()
364-
365 if sess := ac.currentSession(); sess != nil {340 if sess := ac.currentSession(); sess != nil {
366 d.invalidateRender(sess, ac, true, "copymode.go")341 d.invalidateRender(sess, ac, true, "copymode.go")
367 }342 }
368 })343 })
369 }344 }
370-345+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }
371-func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {346+func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
372- rt := ac.overlays
373- rt.copyESC.stop()
374-}
375-
376-func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
377 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {347 if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
378 switch data[2] {348 switch data[2] {
379 case 'A':349 case 'A':
380- m.Move(snap, -1)350+ m.Up()
381 return 3, true351 return 3, true
382 case 'B':352 case 'B':
383- m.Move(snap, 1)353+ m.Down()
384 return 3, true354 return 3, true
385 }355 }
386 }356 }
387 if len(data) >= 4 && data[1] == '[' && data[3] == '~' {357 if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
388 switch data[2] {358 switch data[2] {
389 case '5':359 case '5':
390- m.Page(snap, -1)360+ m.Page(-1)
391 return 4, true361 return 4, true
392 case '6':362 case '6':
393- m.Page(snap, 1)363+ m.Page(1)
394 return 4, true364 return 4, true
395 }365 }
396 }366 }
397 return 0, false367 return 0, false
398 }368 }
399-
400 func isCopyEscapePrefix(data []byte) bool {369 func isCopyEscapePrefix(data []byte) bool {
401- return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') ||370+ 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')
402- len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
403 }371 }
404-372+func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
405-func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {373+ if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
406- if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
407 return frame, nil374 return frame, nil
408 }375 }
409 styles := newThemeStyles(bars.theme)376 styles := newThemeStyles(bars.theme)
410- copyFrame := mode.Render(*document, styles.copyStatus, styles.selection)377+ copyFrame := mode.Render(styles.copyStatus, styles.selection)
411 bodyRows := max(copyFrame.Height-1, 0)378 bodyRows := max(copyFrame.Height-1, 0)
412 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {379 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
413 dstX := max(target.X, 0)380 dstX := max(target.X, 0)
@@ -419,10 +386,8 @@ func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target d
419 }386 }
420 }387 }
421 statusY := frame.Height - 1388 statusY := frame.Height - 1
422- statusRow := frame.Row(statusY)389+ for x := range frame.Row(statusY) {
423- blank := renderer.BlankCell()390+ frame.Row(statusY)[x] = renderer.BlankCell()
424- for x := range statusRow {
425- statusRow[x] = blank
426 }391 }
427 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])392 copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
428 return frame, []renderer.Damage{renderer.FullRedraw()}393 return frame, []renderer.Damage{renderer.FullRedraw()}
M internal/usecase/daemon/copymode_lifecycle_test.go +7 -7
@@ -54,11 +54,11 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
5454 sess.client = ac
5555 ac.setSession(sess)
5656 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.history, closing.screen.Frame)
57+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators)
5858 closing.mu.Unlock()
5959 ac.overlays.copyMu.Lock()
6060 ac.overlays.copyPane = closing
61- ac.overlays.copySnapshot = &document
61+ ac.overlays.copyDocument = document
6262 ac.overlays.copyMode = scopy.NewMode(document)
6363 ac.overlays.copyMu.Unlock()
6464
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
9494 tb := sess.activeTab()
9595 p := tb.focusedPane()
9696 p.mu.Lock()
97- document := scopy.NewSnapshot(p.history, p.screen.Frame)
97+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
9898 p.mu.Unlock()
9999
100100 // Hold session validation after publication and make the captured target
@@ -104,12 +104,12 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
104104 sess.active = 1
105105 result := make(chan bool, 1)
106106 go func() {
107- result <- d.publishCopyMode(sess, ac, tb, p, document, nil)
107+ result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)
108108 }()
109109 published := assert.Eventually(t, func() bool {
110110 ac.overlays.copyMu.Lock()
111111 defer ac.overlays.copyMu.Unlock()
112- return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil
112+ return ac.overlays.copyPane == p && ac.overlays.copyDocument != nil
113113 }, time.Second, time.Millisecond)
114114 if !published {
115115 sess.mu.Unlock()
@@ -157,11 +157,11 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157157 tb := sess.activeTab()
158158 p := tb.focusedPane()
159159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.history, p.screen.Frame)
160+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
161161 p.mu.Unlock()
162162 tc.invalidate(sess, tb, p)
163163
164- require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil))
164+ require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil, nil))
165165 require.False(t, ac.overlays.copyActive())
166166 require.Nil(t, copyTargetPane(ac.overlays))
167167 })
@@ -54,11 +54,11 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
54 sess.client = ac54 sess.client = ac
55 ac.setSession(sess)55 ac.setSession(sess)
56 closing.mu.Lock()56 closing.mu.Lock()
57- document := scopy.NewSnapshot(closing.history, closing.screen.Frame)57+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators)
58 closing.mu.Unlock()58 closing.mu.Unlock()
59 ac.overlays.copyMu.Lock()59 ac.overlays.copyMu.Lock()
60 ac.overlays.copyPane = closing60 ac.overlays.copyPane = closing
61- ac.overlays.copySnapshot = &document61+ ac.overlays.copyDocument = document
62 ac.overlays.copyMode = scopy.NewMode(document)62 ac.overlays.copyMode = scopy.NewMode(document)
63 ac.overlays.copyMu.Unlock()63 ac.overlays.copyMu.Unlock()
64 64
@@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
94 tb := sess.activeTab()94 tb := sess.activeTab()
95 p := tb.focusedPane()95 p := tb.focusedPane()
96 p.mu.Lock()96 p.mu.Lock()
97- document := scopy.NewSnapshot(p.history, p.screen.Frame)97+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
98 p.mu.Unlock()98 p.mu.Unlock()
99 99
100 // Hold session validation after publication and make the captured target100 // Hold session validation after publication and make the captured target
@@ -104,12 +104,12 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
104 sess.active = 1104 sess.active = 1
105 result := make(chan bool, 1)105 result := make(chan bool, 1)
106 go func() {106 go func() {
107- result <- d.publishCopyMode(sess, ac, tb, p, document, nil)107+ result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)
108 }()108 }()
109 published := assert.Eventually(t, func() bool {109 published := assert.Eventually(t, func() bool {
110 ac.overlays.copyMu.Lock()110 ac.overlays.copyMu.Lock()
111 defer ac.overlays.copyMu.Unlock()111 defer ac.overlays.copyMu.Unlock()
112- return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil112+ return ac.overlays.copyPane == p && ac.overlays.copyDocument != nil
113 }, time.Second, time.Millisecond)113 }, time.Second, time.Millisecond)
114 if !published {114 if !published {
115 sess.mu.Unlock()115 sess.mu.Unlock()
@@ -157,11 +157,11 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
157 tb := sess.activeTab()157 tb := sess.activeTab()
158 p := tb.focusedPane()158 p := tb.focusedPane()
159 p.mu.Lock()159 p.mu.Lock()
160- document := scopy.NewSnapshot(p.history, p.screen.Frame)160+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
161 p.mu.Unlock()161 p.mu.Unlock()
162 tc.invalidate(sess, tb, p)162 tc.invalidate(sess, tb, p)
163 163
164- require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil))164+ require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil, nil))
165 require.False(t, ac.overlays.copyActive())165 require.False(t, ac.overlays.copyActive())
166 require.Nil(t, copyTargetPane(ac.overlays))166 require.Nil(t, copyTargetPane(ac.overlays))
167 })167 })
M internal/usecase/daemon/copymode_test.go +20 -20
@@ -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)
@@ -821,15 +821,15 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
821821 press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
822822 d.handleInput(sess, ac, []byte(press))
823823 awaitFrame(t, sends, ports.MsgOutput)
824- require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)
824+ require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor().Row)
825825
826826 motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
827827 d.handleInput(sess, ac, []byte(motion))
828828 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)
829+ selection := ac.overlays.copyMode.Selection()
830+ require.True(t, selection.Enabled)
831+ require.Equal(t, viewportTop+1, selection.Anchor.Row)
832+ require.Equal(t, viewportTop+2, selection.Active.Row)
833833 }
834834
835835 func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
@@ -114,7 +114,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
114 pane.mu.Lock()114 pane.mu.Lock()
115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)115 snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
116 pane.mu.Unlock()116 pane.mu.Unlock()
117- mode := scopy.NewMode(snap)117+ mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118 bars := barState{status: sess.statusSegments(true)}118 bars := barState{status: sess.statusSegments(true)}
119 119
120 base := renderer.NewFrame(80, 25)120 base := renderer.NewFrame(80, 25)
@@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133 go func() {133 go func() {
134 defer wg.Done()134 defer wg.Done()
135 for range 200 {135 for range 200 {
136- _, _ = composeCopyClientFrame(mode, &snap, target, base, bars)136+ _, _ = composeCopyClientFrame(mode, target, base, bars)
137 }137 }
138 }()138 }()
139 wg.Wait()139 wg.Wait()
@@ -148,7 +148,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
148 tb.focusedPane().screen = vt.NewScreen(12, 3)148 tb.focusedPane().screen = vt.NewScreen(12, 3)
149 tb.focusedPane().screen.Write([]byte("live"))149 tb.focusedPane().screen.Write([]byte("live"))
150 snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)150 snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
151- mode := scopy.NewMode(snap)151+ mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
152 152
153 bars := barState{status: sess.statusSegments(true)}153 bars := barState{status: sess.statusSegments(true)}
154 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}154 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
@@ -160,7 +160,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
160 }},160 }},
161 bars: bars,161 bars: bars,
162 }, composeCacheInput{}).frame162 }, composeCacheInput{}).frame
163- frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)163+ frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
164 164
165 require.Equal(t, 80, frame.Width)165 require.Equal(t, 80, frame.Width)
166 require.Equal(t, 25, frame.Height)166 require.Equal(t, 25, frame.Height)
@@ -220,7 +220,7 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
220 220
221 require.Nil(t, ac.overlays.copySearch)221 require.Nil(t, ac.overlays.copySearch)
222 require.NotNil(t, ac.overlays.copyMode)222 require.NotNil(t, ac.overlays.copyMode)
223- require.Equal(t, 0, ac.overlays.copyMode.Cursor)223+ require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
224 select {224 select {
225 case got := <-writes:225 case got := <-writes:
226 t.Fatalf("batched visual search input forwarded to PTY: %q", got)226 t.Fatalf("batched visual search input forwarded to PTY: %q", got)
@@ -257,14 +257,14 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
257 d.handleInput(sess, ac, []byte("\r"))257 d.handleInput(sess, ac, []byte("\r"))
258 awaitFrame(t, sends, ports.MsgOutput)258 awaitFrame(t, sends, ports.MsgOutput)
259 require.Nil(t, ac.overlays.copySearch)259 require.Nil(t, ac.overlays.copySearch)
260- require.Equal(t, 0, ac.overlays.copyMode.Cursor)260+ require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
261 261
262 d.handleInput(sess, ac, []byte("n"))262 d.handleInput(sess, ac, []byte("n"))
263 awaitFrame(t, sends, ports.MsgOutput)263 awaitFrame(t, sends, ports.MsgOutput)
264- require.Equal(t, 1, ac.overlays.copyMode.Cursor)264+ require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row)
265 d.handleInput(sess, ac, []byte("N"))265 d.handleInput(sess, ac, []byte("N"))
266 awaitFrame(t, sends, ports.MsgOutput)266 awaitFrame(t, sends, ports.MsgOutput)
267- require.Equal(t, 0, ac.overlays.copyMode.Cursor)267+ require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row)
268 268
269 select {269 select {
270 case got := <-writes:270 case got := <-writes:
@@ -289,12 +289,12 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
289 d.handleInput(sess, ac, []byte("alpha"))289 d.handleInput(sess, ac, []byte("alpha"))
290 awaitFrame(t, sends, ports.MsgOutput)290 awaitFrame(t, sends, ports.MsgOutput)
291 require.NotNil(t, ac.overlays.copySearch)291 require.NotNil(t, ac.overlays.copySearch)
292- require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal")292+ require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row, "typing a query previews the selected first result behind the modal")
293 293
294 d.handleInput(sess, ac, []byte{0x0e})294 d.handleInput(sess, ac, []byte{0x0e})
295 awaitFrame(t, sends, ports.MsgOutput)295 awaitFrame(t, sends, ports.MsgOutput)
296 require.NotNil(t, ac.overlays.copySearch)296 require.NotNil(t, ac.overlays.copySearch)
297- require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter")297+ require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row, "moving modal selection previews that result without Enter")
298 }298 }
299 299
300 func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {300 func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
@@ -310,11 +310,11 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
310 d.handleInput(sess, ac, []byte("/alpha"))310 d.handleInput(sess, ac, []byte("/alpha"))
311 awaitFrame(t, sends, ports.MsgOutput)311 awaitFrame(t, sends, ports.MsgOutput)
312 require.NotNil(t, ac.overlays.copySearch)312 require.NotNil(t, ac.overlays.copySearch)
313- cursor := ac.overlays.copyMode.Cursor313+ cursor := ac.overlays.copyMode.Cursor().Row
314 314
315 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))315 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
316 require.NotNil(t, ac.overlays.copySearch)316 require.NotNil(t, ac.overlays.copySearch)
317- require.Equal(t, cursor, ac.overlays.copyMode.Cursor)317+ require.Equal(t, cursor, ac.overlays.copyMode.Cursor().Row)
318 318
319 d.handleInput(sess, ac, []byte("\x1b"))319 d.handleInput(sess, ac, []byte("\x1b"))
320 awaitFrame(t, sends, ports.MsgOutput)320 awaitFrame(t, sends, ports.MsgOutput)
@@ -517,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
517 }517 }
518 518
519 require.NotNil(t, ac.overlays.copyMode)519 require.NotNil(t, ac.overlays.copyMode)
520- require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor)520+ require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor().Row)
521 })521 })
522 }522 }
523 }523 }
@@ -790,11 +790,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
790 790
791 d.enterCopyMode(sess, ac)791 d.enterCopyMode(sess, ac)
792 awaitFrame(t, sends, ports.MsgOutput)792 awaitFrame(t, sends, ports.MsgOutput)
793- require.Equal(t, total-1, ac.overlays.copyMode.Cursor)793+ require.Equal(t, total-1, ac.overlays.copyMode.Cursor().Row)
794 794
795 d.copyWheel(sess, ac, -3)795 d.copyWheel(sess, ac, -3)
796 awaitFrame(t, sends, ports.MsgOutput)796 awaitFrame(t, sends, ports.MsgOutput)
797- require.Equal(t, total-4, ac.overlays.copyMode.Cursor)797+ require.Equal(t, total-4, ac.overlays.copyMode.Cursor().Row)
798 798
799 d.copyWheel(sess, ac, 3)799 d.copyWheel(sess, ac, 3)
800 awaitFrame(t, sends, ports.MsgOutput)800 awaitFrame(t, sends, ports.MsgOutput)
@@ -821,15 +821,15 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
821 press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)821 press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
822 d.handleInput(sess, ac, []byte(press))822 d.handleInput(sess, ac, []byte(press))
823 awaitFrame(t, sends, ports.MsgOutput)823 awaitFrame(t, sends, ports.MsgOutput)
824- require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)824+ require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor().Row)
825 825
826 motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)826 motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
827 d.handleInput(sess, ac, []byte(motion))827 d.handleInput(sess, ac, []byte(motion))
828 awaitFrame(t, sends, ports.MsgOutput)828 awaitFrame(t, sends, ports.MsgOutput)
829- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()829+ selection := ac.overlays.copyMode.Selection()
830- require.True(t, ok)830+ require.True(t, selection.Enabled)
831- require.Equal(t, viewportTop+1, lo)831+ require.Equal(t, viewportTop+1, selection.Anchor.Row)
832- require.Equal(t, viewportTop+2, hi)832+ require.Equal(t, viewportTop+2, selection.Active.Row)
833 }833 }
834 834
835 func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {835 func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
M internal/usecase/daemon/input.go +9 -7
@@ -249,19 +249,21 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
249249
250250 // The immutable snapshot is the only live-pane access needed for drag entry.
251251 p.mu.Lock()
252- document := scopy.NewSnapshot(p.history, p.screen.Frame)
252+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
253253 p.mu.Unlock()
254+ cfg := d.currentCopyConfig()
255+ document := scopy.NewDocument(snapshot, cfg.WordSeparators)
254256 tb := sess.activeTab()
255257 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- }) {
258+ mode.SetPosition(scopy.Pos{Row: pressTop + pressRow, Col: 0})
259+ mode.ToggleLineSelection()
260+ mode.MoveRows(document.Len() - document.Height() + ev.Row - mode.Cursor().Row)
261+ }, nil) {
259262 return
260263 }
261264 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+ // Row-compatible temporary pointer state; phase 4 supplies exact columns.
266+ if rt.copyPane == p && rt.copyDocument == document && rt.copyMode != nil {
265267 rt.copyPressRow = pressTop + pressRow
266268 rt.copyPressRowValid = true
267269 rt.copyDragging = true
@@ -249,19 +249,21 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
249 249
250 // The immutable snapshot is the only live-pane access needed for drag entry.250 // The immutable snapshot is the only live-pane access needed for drag entry.
251 p.mu.Lock()251 p.mu.Lock()
252- document := scopy.NewSnapshot(p.history, p.screen.Frame)252+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
253 p.mu.Unlock()253 p.mu.Unlock()
254+ cfg := d.currentCopyConfig()
255+ document := scopy.NewDocument(snapshot, cfg.WordSeparators)
254 tb := sess.activeTab()256 tb := sess.activeTab()
255 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {257 if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
256- mode.StartSelectionAt(document, pressTop+pressRow)258+ mode.SetPosition(scopy.Pos{Row: pressTop + pressRow, Col: 0})
257- mode.ExtendTo(document, document.Len()-document.Height+ev.Row)259+ mode.ToggleLineSelection()
258- }) {260+ mode.MoveRows(document.Len() - document.Height() + ev.Row - mode.Cursor().Row)
261+ }, nil) {
259 return262 return
260 }263 }
261 rt.copyMu.Lock()264 rt.copyMu.Lock()
262- // Publication is revalidated before this state is installed, and no265+ // Row-compatible temporary pointer state; phase 4 supplies exact columns.
263- // lifecycle lock is held while copyMu is taken.266+ if rt.copyPane == p && rt.copyDocument == document && rt.copyMode != nil {
264- if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil {
265 rt.copyPressRow = pressTop + pressRow267 rt.copyPressRow = pressTop + pressRow
266 rt.copyPressRowValid = true268 rt.copyPressRowValid = true
267 rt.copyDragging = true269 rt.copyDragging = true
M internal/usecase/daemon/input_test.go +37 -13
@@ -844,7 +844,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
844844 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
845845 data := mustOutputData(t, sends)
846846 require.NotNil(t, ac.overlays.copyMode)
847- require.Equal(t, 19, ac.overlays.copyMode.Cursor)
847+ require.Equal(t, 19, ac.overlays.copyMode.Cursor().Row)
848848 require.Contains(t, string(data), "[SCROLL]")
849849
850850 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -950,8 +950,12 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
950950 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
951951 mustOutputData(t, sends)
952952 require.NotNil(t, ac.overlays.copyMode)
953- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
954- require.True(t, ok)
953+ selection := ac.overlays.copyMode.Selection()
954+ require.True(t, selection.Enabled)
955+ lo, hi := selection.Anchor.Row, selection.Active.Row
956+ if lo > hi {
957+ lo, hi = hi, lo
958+ }
955959 require.Equal(t, 0, lo)
956960 require.Equal(t, 2, hi)
957961 }
@@ -968,8 +972,12 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
968972 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
969973 mustOutputData(t, sends)
970974 require.NotNil(t, ac.overlays.copyMode)
971- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
972- require.True(t, ok)
975+ selection := ac.overlays.copyMode.Selection()
976+ require.True(t, selection.Enabled)
977+ lo, hi := selection.Anchor.Row, selection.Active.Row
978+ if lo > hi {
979+ lo, hi = hi, lo
980+ }
973981 require.Equal(t, 0, lo)
974982 require.Equal(t, 1, hi)
975983
@@ -1039,8 +1047,12 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10391047 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
10401048 mustOutputData(t, sends)
10411049 require.NotNil(t, ac.overlays.copyMode)
1042- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
1043- require.True(t, ok)
1050+ selection := ac.overlays.copyMode.Selection()
1051+ require.True(t, selection.Enabled)
1052+ lo, hi := selection.Anchor.Row, selection.Active.Row
1053+ if lo > hi {
1054+ lo, hi = hi, lo
1055+ }
10441056 require.Equal(t, 0, lo)
10451057 require.Equal(t, 1, hi)
10461058
@@ -1053,8 +1065,12 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
10531065 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
10541066
10551067 require.NotNil(t, ac.overlays.copyMode)
1056- lo, hi, ok = ac.overlays.copyMode.SelectedBounds()
1057- require.True(t, ok)
1068+ selection = ac.overlays.copyMode.Selection()
1069+ require.True(t, selection.Enabled)
1070+ lo, hi = selection.Anchor.Row, selection.Active.Row
1071+ if lo > hi {
1072+ lo, hi = hi, lo
1073+ }
10581074 require.Equal(t, 0, lo, "anchor must not have moved")
10591075 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
10601076 }
@@ -1087,8 +1103,12 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
10871103 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
10881104
10891105 require.NotNil(t, ac.overlays.copyMode)
1090- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
1091- require.True(t, ok)
1106+ selection := ac.overlays.copyMode.Selection()
1107+ require.True(t, selection.Enabled)
1108+ lo, hi := selection.Anchor.Row, selection.Active.Row
1109+ if lo > hi {
1110+ lo, hi = hi, lo
1111+ }
10921112 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
10931113 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
10941114 }
@@ -1202,8 +1222,12 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
12021222 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
12031223 mustOutputData(t, sends)
12041224
1205- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
1206- require.True(t, ok)
1225+ selection := ac.overlays.copyMode.Selection()
1226+ require.True(t, selection.Enabled)
1227+ lo, hi := selection.Anchor.Row, selection.Active.Row
1228+ if lo > hi {
1229+ lo, hi = hi, lo
1230+ }
12071231 require.Equal(t, 0, lo)
12081232 require.Equal(t, 9, hi)
12091233 }
@@ -844,7 +844,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
844 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))844 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
845 data := mustOutputData(t, sends)845 data := mustOutputData(t, sends)
846 require.NotNil(t, ac.overlays.copyMode)846 require.NotNil(t, ac.overlays.copyMode)
847- require.Equal(t, 19, ac.overlays.copyMode.Cursor)847+ require.Equal(t, 19, ac.overlays.copyMode.Cursor().Row)
848 require.Contains(t, string(data), "[SCROLL]")848 require.Contains(t, string(data), "[SCROLL]")
849 849
850 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))850 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -950,8 +950,12 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
950 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))950 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
951 mustOutputData(t, sends)951 mustOutputData(t, sends)
952 require.NotNil(t, ac.overlays.copyMode)952 require.NotNil(t, ac.overlays.copyMode)
953- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()953+ selection := ac.overlays.copyMode.Selection()
954- require.True(t, ok)954+ require.True(t, selection.Enabled)
955+ lo, hi := selection.Anchor.Row, selection.Active.Row
956+ if lo > hi {
957+ lo, hi = hi, lo
958+ }
955 require.Equal(t, 0, lo)959 require.Equal(t, 0, lo)
956 require.Equal(t, 2, hi)960 require.Equal(t, 2, hi)
957 }961 }
@@ -968,8 +972,12 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
968 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))972 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
969 mustOutputData(t, sends)973 mustOutputData(t, sends)
970 require.NotNil(t, ac.overlays.copyMode)974 require.NotNil(t, ac.overlays.copyMode)
971- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()975+ selection := ac.overlays.copyMode.Selection()
972- require.True(t, ok)976+ require.True(t, selection.Enabled)
977+ lo, hi := selection.Anchor.Row, selection.Active.Row
978+ if lo > hi {
979+ lo, hi = hi, lo
980+ }
973 require.Equal(t, 0, lo)981 require.Equal(t, 0, lo)
974 require.Equal(t, 1, hi)982 require.Equal(t, 1, hi)
975 983
@@ -1039,8 +1047,12 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1039 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))1047 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
1040 mustOutputData(t, sends)1048 mustOutputData(t, sends)
1041 require.NotNil(t, ac.overlays.copyMode)1049 require.NotNil(t, ac.overlays.copyMode)
1042- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()1050+ selection := ac.overlays.copyMode.Selection()
1043- require.True(t, ok)1051+ require.True(t, selection.Enabled)
1052+ lo, hi := selection.Anchor.Row, selection.Active.Row
1053+ if lo > hi {
1054+ lo, hi = hi, lo
1055+ }
1044 require.Equal(t, 0, lo)1056 require.Equal(t, 0, lo)
1045 require.Equal(t, 1, hi)1057 require.Equal(t, 1, hi)
1046 1058
@@ -1053,8 +1065,12 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1053 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))1065 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1054 1066
1055 require.NotNil(t, ac.overlays.copyMode)1067 require.NotNil(t, ac.overlays.copyMode)
1056- lo, hi, ok = ac.overlays.copyMode.SelectedBounds()1068+ selection = ac.overlays.copyMode.Selection()
1057- require.True(t, ok)1069+ require.True(t, selection.Enabled)
1070+ lo, hi = selection.Anchor.Row, selection.Active.Row
1071+ if lo > hi {
1072+ lo, hi = hi, lo
1073+ }
1058 require.Equal(t, 0, lo, "anchor must not have moved")1074 require.Equal(t, 0, lo, "anchor must not have moved")
1059 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")1075 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
1060 }1076 }
@@ -1087,8 +1103,12 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
1087 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))1103 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1088 1104
1089 require.NotNil(t, ac.overlays.copyMode)1105 require.NotNil(t, ac.overlays.copyMode)
1090- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()1106+ selection := ac.overlays.copyMode.Selection()
1091- require.True(t, ok)1107+ require.True(t, selection.Enabled)
1108+ lo, hi := selection.Anchor.Row, selection.Active.Row
1109+ if lo > hi {
1110+ lo, hi = hi, lo
1111+ }
1092 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")1112 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")1113 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
1094 }1114 }
@@ -1202,8 +1222,12 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1202 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))1222 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
1203 mustOutputData(t, sends)1223 mustOutputData(t, sends)
1204 1224
1205- lo, hi, ok := ac.overlays.copyMode.SelectedBounds()1225+ selection := ac.overlays.copyMode.Selection()
1206- require.True(t, ok)1226+ require.True(t, selection.Enabled)
1227+ lo, hi := selection.Anchor.Row, selection.Active.Row
1228+ if lo > hi {
1229+ lo, hi = hi, lo
1230+ }
1207 require.Equal(t, 0, lo)1231 require.Equal(t, 0, lo)
1208 require.Equal(t, 9, hi)1232 require.Equal(t, 9, hi)
1209 }1233 }
M internal/usecase/daemon/overlay_runtime.go +4 -4
@@ -36,7 +36,7 @@ type overlayRuntime struct {
3636 copyMu sync.Mutex
3737 copyMode *scopy.Mode
3838 copyCandidate *scopy.Mode
39- copySnapshot *scopy.Snapshot
39+ copyDocument *scopy.Document
4040 copyPane *pane
4141 copyPending []byte
4242 copyESC pendingByteTimer
@@ -110,7 +110,7 @@ func (rt *overlayRuntime) copySearchActive() bool {
110110 func (rt *overlayRuntime) clearCopyModeLocked() {
111111 rt.copyMode = nil
112112 rt.copyCandidate = nil
113- rt.copySnapshot = nil
113+ rt.copyDocument = nil
114114 rt.copyPane = nil
115115 rt.copySearch = nil
116116 rt.copySearchPending = nil
@@ -158,7 +158,7 @@ type overlayRenderSnapshot struct {
158158
159159 copyActive bool
160160 copyMode *scopy.Mode
161- copySnapshot *scopy.Snapshot
161+ copyDocument *scopy.Document
162162 copyPane *pane
163163 copySearchModel *visualsearch.Model
164164 copyFeedback string
@@ -195,7 +195,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
195195 rt.copyMu.Lock()
196196 snap.copyActive = rt.copyMode != nil
197197 snap.copyPane = rt.copyPane
198- snap.copySnapshot = rt.copySnapshot
198+ snap.copyDocument = rt.copyDocument
199199 if rt.copyMode != nil {
200200 copyModeValue := *rt.copyMode
201201 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
@@ -36,7 +36,7 @@ type overlayRuntime struct {
36 copyMu sync.Mutex36 copyMu sync.Mutex
37 copyMode *scopy.Mode37 copyMode *scopy.Mode
38 copyCandidate *scopy.Mode38 copyCandidate *scopy.Mode
39- copySnapshot *scopy.Snapshot39+ copyDocument *scopy.Document
40 copyPane *pane40 copyPane *pane
41 copyPending []byte41 copyPending []byte
42 copyESC pendingByteTimer42 copyESC pendingByteTimer
@@ -110,7 +110,7 @@ func (rt *overlayRuntime) copySearchActive() bool {
110 func (rt *overlayRuntime) clearCopyModeLocked() {110 func (rt *overlayRuntime) clearCopyModeLocked() {
111 rt.copyMode = nil111 rt.copyMode = nil
112 rt.copyCandidate = nil112 rt.copyCandidate = nil
113- rt.copySnapshot = nil113+ rt.copyDocument = nil
114 rt.copyPane = nil114 rt.copyPane = nil
115 rt.copySearch = nil115 rt.copySearch = nil
116 rt.copySearchPending = nil116 rt.copySearchPending = nil
@@ -158,7 +158,7 @@ type overlayRenderSnapshot struct {
158 158
159 copyActive bool159 copyActive bool
160 copyMode *scopy.Mode160 copyMode *scopy.Mode
161- copySnapshot *scopy.Snapshot161+ copyDocument *scopy.Document
162 copyPane *pane162 copyPane *pane
163 copySearchModel *visualsearch.Model163 copySearchModel *visualsearch.Model
164 copyFeedback string164 copyFeedback string
@@ -195,7 +195,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
195 rt.copyMu.Lock()195 rt.copyMu.Lock()
196 snap.copyActive = rt.copyMode != nil196 snap.copyActive = rt.copyMode != nil
197 snap.copyPane = rt.copyPane197 snap.copyPane = rt.copyPane
198- snap.copySnapshot = rt.copySnapshot198+ snap.copyDocument = rt.copyDocument
199 if rt.copyMode != nil {199 if rt.copyMode != nil {
200 copyModeValue := *rt.copyMode200 copyModeValue := *rt.copyMode
201 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)201 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
M internal/usecase/daemon/overlay_runtime_test.go +10 -10
@@ -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)
@@ -149,24 +149,24 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
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.Same(t, document, renderSnap.copyDocument)
163163 renderSnap.Unlock()
164164
165165 ac.overlays.copyMu.Lock()
166166 ac.overlays.clearCopyModeLocked()
167167 require.Nil(t, ac.overlays.copyPane)
168168 require.Nil(t, ac.overlays.copyMode)
169- require.Nil(t, ac.overlays.copySnapshot)
169+ require.Nil(t, ac.overlays.copyDocument)
170170 ac.overlays.copyMu.Unlock()
171171 }
172172
@@ -175,16 +175,16 @@ func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
175175 ac.initOverlays()
176176 history := newTestHistory(1)
177177 history.Append(testRow("before"))
178- document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
178+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
179179
180180 ac.overlays.copyMu.Lock()
181181 ac.overlays.copyMode = scopy.NewMode(document)
182- ac.overlays.copySnapshot = &document
182+ ac.overlays.copyDocument = document
183183 ac.overlays.copyMu.Unlock()
184184
185185 history.Append(testRow("after"))
186186 renderSnap := ac.overlays.SnapshotForRender()
187187 defer renderSnap.Unlock()
188- require.Same(t, &document, renderSnap.copySnapshot)
189- require.Equal(t, "before", rowText(renderSnap.copySnapshot.Row(0)))
188+ require.Same(t, document, renderSnap.copyDocument)
189+ require.Equal(t, "before", rowText(renderSnap.copyDocument.Snapshot().Row(0)))
190190 }
@@ -114,8 +114,8 @@ func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
114 ac := &attachedClient{}114 ac := &attachedClient{}
115 ac.initOverlays()115 ac.initOverlays()
116 snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2)116 snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2)
117- mode := scopy.NewMode(snap)117+ mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118- require.True(t, mode.Search(snap, "alpha"))118+ require.True(t, mode.Search("alpha"))
119 search := visualsearch.New(snap)119 search := visualsearch.New(snap)
120 for _, r := range "alpha" {120 for _, r := range "alpha" {
121 search.Insert(r)121 search.Insert(r)
@@ -149,24 +149,24 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.
149 ac := &attachedClient{}149 ac := &attachedClient{}
150 ac.initOverlays()150 ac.initOverlays()
151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152- document := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)152+ document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2), domain.DefaultWordSeparators)
153 153
154 ac.overlays.copyMu.Lock()154 ac.overlays.copyMu.Lock()
155 ac.overlays.copyMode = scopy.NewMode(document)155 ac.overlays.copyMode = scopy.NewMode(document)
156 ac.overlays.copyPane = p156 ac.overlays.copyPane = p
157- ac.overlays.copySnapshot = &document157+ ac.overlays.copyDocument = document
158 ac.overlays.copyMu.Unlock()158 ac.overlays.copyMu.Unlock()
159 159
160 renderSnap := ac.overlays.SnapshotForRender()160 renderSnap := ac.overlays.SnapshotForRender()
161 require.Same(t, p, renderSnap.copyPane)161 require.Same(t, p, renderSnap.copyPane)
162- require.Same(t, &document, renderSnap.copySnapshot)162+ require.Same(t, document, renderSnap.copyDocument)
163 renderSnap.Unlock()163 renderSnap.Unlock()
164 164
165 ac.overlays.copyMu.Lock()165 ac.overlays.copyMu.Lock()
166 ac.overlays.clearCopyModeLocked()166 ac.overlays.clearCopyModeLocked()
167 require.Nil(t, ac.overlays.copyPane)167 require.Nil(t, ac.overlays.copyPane)
168 require.Nil(t, ac.overlays.copyMode)168 require.Nil(t, ac.overlays.copyMode)
169- require.Nil(t, ac.overlays.copySnapshot)169+ require.Nil(t, ac.overlays.copyDocument)
170 ac.overlays.copyMu.Unlock()170 ac.overlays.copyMu.Unlock()
171 }171 }
172 172
@@ -175,16 +175,16 @@ func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
175 ac.initOverlays()175 ac.initOverlays()
176 history := newTestHistory(1)176 history := newTestHistory(1)
177 history.Append(testRow("before"))177 history.Append(testRow("before"))
178- document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))178+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
179 179
180 ac.overlays.copyMu.Lock()180 ac.overlays.copyMu.Lock()
181 ac.overlays.copyMode = scopy.NewMode(document)181 ac.overlays.copyMode = scopy.NewMode(document)
182- ac.overlays.copySnapshot = &document182+ ac.overlays.copyDocument = document
183 ac.overlays.copyMu.Unlock()183 ac.overlays.copyMu.Unlock()
184 184
185 history.Append(testRow("after"))185 history.Append(testRow("after"))
186 renderSnap := ac.overlays.SnapshotForRender()186 renderSnap := ac.overlays.SnapshotForRender()
187 defer renderSnap.Unlock()187 defer renderSnap.Unlock()
188- require.Same(t, &document, renderSnap.copySnapshot)188+ require.Same(t, document, renderSnap.copyDocument)
189- require.Equal(t, "before", rowText(renderSnap.copySnapshot.Row(0)))189+ require.Equal(t, "before", rowText(renderSnap.copyDocument.Snapshot().Row(0)))
190 }190 }
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() {
@@ -869,7 +869,7 @@ func (f *performanceFixture) searchMatches() int {
869 func (f *performanceFixture) copyModeActive() bool {869 func (f *performanceFixture) copyModeActive() bool {
870 f.ac.overlays.copyMu.Lock()870 f.ac.overlays.copyMu.Lock()
871 defer f.ac.overlays.copyMu.Unlock()871 defer f.ac.overlays.copyMu.Unlock()
872- return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000872+ return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000
873 }873 }
874 874
875 func (f *performanceFixture) resize() {875 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
@@ -84,7 +84,6 @@ type capturedFloatingRenderState struct {
84 type capturedOverlayRenderState struct {84 type capturedOverlayRenderState struct {
85 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool85 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
86 copyMode *scopy.Mode86 copyMode *scopy.Mode
87- copySnapshot *scopy.Snapshot
88 copyPaneID layout.PaneID87 copyPaneID layout.PaneID
89 copyFeedback string88 copyFeedback string
90 paletteGuidance string89 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 {
@@ -185,7 +185,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
185 }185 }
186 o := &state.overlays186 o := &state.overlays
187 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive187 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.copySnapshot188+ o.copyMode = snap.copyMode
189 if snap.copyPane != nil {189 if snap.copyPane != nil {
190 o.copyPaneID = snap.copyPane.id190 o.copyPaneID = snap.copyPane.id
191 }191 }
@@ -236,7 +236,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
236 }236 }
237 }237 }
238 }238 }
239- frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)239+ frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars)
240 }240 }
241 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}241 layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
242 if o.paletteActive && !state.floating.visible {242 if o.paletteActive && !state.floating.visible {
M internal/usecase/daemon/render_test.go +2 -2
@@ -1175,10 +1175,10 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
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)))
@@ -1175,10 +1175,10 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1175 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1175 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1176 p.screen.Write([]byte("ab\r\ncd"))1176 p.screen.Write([]byte("ab\r\ncd"))
1177 document := scopy.NewSnapshot(p.history, p.screen.Frame)1177 document := scopy.NewSnapshot(p.history, p.screen.Frame)
1178- mode := scopy.NewMode(document)1178+ mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1179 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1180 1180
1181- frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{})1181+ frame, damage := composeCopyClientFrame(mode, target, base, barState{})
1182 1182
1183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)1183 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1184 require.Equal(t, "##ab ", rowText(frame.Row(3)))1184 require.Equal(t, "##ab ", rowText(frame.Row(3)))
M internal/usecase/visualsearch/model.go +1 -1
@@ -106,7 +106,7 @@ func (m *Model) Matches() []scopy.SearchMatch {
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 }
@@ -106,7 +106,7 @@ func (m *Model) Matches() []scopy.SearchMatch {
106 }106 }
107 107
108 func (m *Model) refresh() {108 func (m *Model) refresh() {
109- m.matches = scopy.FindMatches(m.snapshot, m.input.Value())109+ m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value())
110 m.clamp()110 m.clamp()
111 m.ensureVisible(defaultVisibleRows)111 m.ensureVisible(defaultVisibleRows)
112 }112 }

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