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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +551 -1065
M internal/usecase/copy/mode.go +274 -250
| @@ -10,26 +10,15 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/pkg/vt" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // OSC52MaxPayloadBytes caps clipboard payloads while vev intentionally emits | |
| 14 | - | // exactly one OSC 52 sequence. Splitting one clipboard copy across multiple OSC | |
| 15 | - | // 52 sequences replaces the clipboard repeatedly in common terminals, corrupting | |
| 16 | - | // the intended result, so oversized selections are deferred until a terminal- | |
| 17 | - | // specific continuation protocol is supported. | |
| 18 | 13 | const OSC52MaxPayloadBytes = 75_000 | |
| 19 | 14 | ||
| 20 | - | // Snapshot is the immutable scrollback-mode document: history followed by the | |
| 21 | - | // visible screen. VT HistoryView retains immutable chunks; the visible frame is | |
| 22 | - | // cloned once when the snapshot is constructed. | |
| 15 | + | // Snapshot is the immutable scrollback document: sealed history plus a cloned screen. | |
| 23 | 16 | type Snapshot struct { | |
| 24 | - | history vt.HistoryView | |
| 25 | - | screen renderer.Frame | |
| 26 | - | Width int | |
| 27 | - | Height int | |
| 17 | + | history vt.HistoryView | |
| 18 | + | screen renderer.Frame | |
| 19 | + | Width, Height int | |
| 28 | 20 | } | |
| 29 | 21 | ||
| 30 | - | // NewSnapshot seals the current VT history tail, then freezes that immutable | |
| 31 | - | // view and clones the visible screen. Sealing prevents repeated copy entry from | |
| 32 | - | // cloning every row in a partial tail. | |
| 33 | 22 | func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |
| 34 | 23 | var history vt.HistoryView | |
| 35 | 24 | if historySource != nil { | |
| @@ -37,11 +26,6 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot { | |||
| 37 | 26 | } | |
| 38 | 27 | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 39 | 28 | } | |
| 40 | - | ||
| 41 | - | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | |
| 42 | - | // It is intended for callers that already have a complete document rather than | |
| 43 | - | // a scrollback and visible frame. Height is viewport geometry, not a request to | |
| 44 | - | // append blank document rows. | |
| 45 | 29 | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 46 | 30 | history := vt.NewHistory(vt.HistoryConfig{MaxRows: len(rows), ChunkRows: 256}) | |
| 47 | 31 | for _, row := range rows { | |
| @@ -49,12 +33,7 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 49 | 33 | } | |
| 50 | 34 | return Snapshot{history: history.SealAndView(), Width: width, Height: height} | |
| 51 | 35 | } | |
| 52 | - | ||
| 53 | - | // Len returns the number of document rows. | |
| 54 | 36 | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | |
| 55 | - | ||
| 56 | - | // Row returns document row i, or nil when i is out of range. History rows | |
| 57 | - | // borrow immutable history storage and must be treated as read-only. | |
| 58 | 37 | func (s Snapshot) Row(i int) []renderer.Cell { | |
| 59 | 38 | if i < 0 { | |
| 60 | 39 | return nil | |
| @@ -68,321 +47,370 @@ func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 68 | 47 | } | |
| 69 | 48 | return s.screen.Row(i) | |
| 70 | 49 | } | |
| 71 | - | ||
| 72 | 50 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |
| 73 | - | rowIndex := 0 | |
| 51 | + | i := 0 | |
| 74 | 52 | stopped := false | |
| 75 | - | s.history.Range(func(row []renderer.Cell) bool { | |
| 76 | - | if !yield(rowIndex, row) { | |
| 53 | + | s.history.Range(func(r []renderer.Cell) bool { | |
| 54 | + | if !yield(i, r) { | |
| 77 | 55 | stopped = true | |
| 78 | 56 | return false | |
| 79 | 57 | } | |
| 80 | - | rowIndex++ | |
| 58 | + | i++ | |
| 81 | 59 | return true | |
| 82 | 60 | }) | |
| 83 | 61 | if stopped { | |
| 84 | 62 | return | |
| 85 | 63 | } | |
| 86 | 64 | for y := range s.screen.Height { | |
| 87 | - | if !yield(rowIndex+y, s.screen.Row(y)) { | |
| 65 | + | if !yield(i+y, s.screen.Row(y)) { | |
| 88 | 66 | return | |
| 89 | 67 | } | |
| 90 | 68 | } | |
| 91 | 69 | } | |
| 92 | 70 | ||
| 93 | - | // Mode stores per-client scrollback viewport and line-selection state. | |
| 94 | 71 | type SearchMatch struct { | |
| 95 | - | Row int | |
| 96 | - | Start int | |
| 97 | - | End int | |
| 98 | - | Text string | |
| 99 | - | } | |
| 72 | + | Row, Start, End int | |
| 73 | + | Text string | |
| 74 | + | } // End is exclusive display-cell offset. | |
| 100 | 75 | ||
| 101 | 76 | type Mode struct { | |
| 77 | + | document *Document | |
| 78 | + | navigator Navigator | |
| 79 | + | selection Selection | |
| 102 | 80 | ViewportTop int | |
| 103 | - | Cursor int | |
| 104 | - | Anchor int | |
| 105 | - | Selecting bool | |
| 106 | 81 | SearchQuery string | |
| 107 | 82 | Searches []SearchMatch | |
| 108 | 83 | SearchIndex int | |
| 109 | 84 | } | |
| 110 | 85 | ||
| 111 | - | func NewMode(s Snapshot) *Mode { | |
| 112 | - | m := &Mode{Anchor: -1} | |
| 113 | - | m.ViewportTop = max(s.Len()-s.Height, 0) | |
| 114 | - | m.Cursor = max(s.Len()-1, 0) | |
| 115 | - | m.clamp(s) | |
| 86 | + | func NewMode(doc *Document) *Mode { | |
| 87 | + | m := &Mode{document: doc, SearchIndex: -1} | |
| 88 | + | if doc != nil && doc.Len() > 0 { | |
| 89 | + | m.navigator = NewNavigator(Pos{Row: doc.Len() - 1}) | |
| 90 | + | m.navigator.Set(doc, m.navigator.Pos) | |
| 91 | + | m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0) | |
| 92 | + | } | |
| 116 | 93 | return m | |
| 117 | 94 | } | |
| 118 | - | ||
| 119 | - | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | |
| 120 | - | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 121 | - | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | |
| 122 | - | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) } | |
| 123 | - | ||
| 124 | - | func FindMatches(s Snapshot, query string) []SearchMatch { | |
| 125 | - | query = strings.TrimSpace(query) | |
| 126 | - | if query == "" { | |
| 95 | + | func (m *Mode) Document() *Document { | |
| 96 | + | if m == nil { | |
| 127 | 97 | return nil | |
| 128 | 98 | } | |
| 129 | - | needle := lowerRunes(query) | |
| 130 | - | matches := make([]SearchMatch, 0) | |
| 131 | - | s.rangeRows(func(row int, cells []renderer.Cell) bool { | |
| 132 | - | haystack, cellIndexes := searchableCells(cells) | |
| 133 | - | var text string | |
| 134 | - | for start := 0; start+len(needle) <= len(haystack); { | |
| 135 | - | if !slices.Equal(haystack[start:start+len(needle)], needle) { | |
| 136 | - | start++ | |
| 137 | - | continue | |
| 138 | - | } | |
| 139 | - | if text == "" { | |
| 140 | - | text = rowString(cells) | |
| 141 | - | } | |
| 142 | - | end := start + len(needle) | |
| 143 | - | cellEnd := len(cells) | |
| 144 | - | if end < len(cellIndexes) { | |
| 145 | - | cellEnd = cellIndexes[end] | |
| 146 | - | } | |
| 147 | - | matches = append(matches, SearchMatch{Row: row, Start: cellIndexes[start], End: cellEnd, Text: text}) | |
| 148 | - | start += len(needle) | |
| 149 | - | } | |
| 150 | - | return true | |
| 151 | - | }) | |
| 152 | - | return matches | |
| 99 | + | return m.document | |
| 153 | 100 | } | |
| 154 | - | ||
| 155 | - | func searchableCells(cells []renderer.Cell) ([]rune, []int) { | |
| 156 | - | runes := make([]rune, 0, len(cells)) | |
| 157 | - | cellIndexes := make([]int, 0, len(cells)) | |
| 158 | - | for x, cell := range cells { | |
| 159 | - | if cell.Continuation { | |
| 160 | - | continue | |
| 101 | + | func (m *Mode) Cursor() Pos { | |
| 102 | + | if m == nil { | |
| 103 | + | return Pos{} | |
| 104 | + | } | |
| 105 | + | return m.navigator.Pos | |
| 106 | + | } | |
| 107 | + | func (m *Mode) Selection() Selection { | |
| 108 | + | if m == nil { | |
| 109 | + | return Selection{} | |
| 110 | + | } | |
| 111 | + | return m.selection | |
| 112 | + | } | |
| 113 | + | func (m *Mode) SetPosition(p Pos) bool { | |
| 114 | + | if m == nil { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | changed := m.navigator.Set(m.document, p) | |
| 118 | + | m.adjustViewport() | |
| 119 | + | return changed | |
| 120 | + | } | |
| 121 | + | func (m *Mode) move(op func(*Document) bool, stream bool) bool { | |
| 122 | + | if m == nil || m.document == nil { | |
| 123 | + | return false | |
| 124 | + | } | |
| 125 | + | if stream && m.selection.Enabled && m.selection.Granularity == Line { | |
| 126 | + | m.selection.AsCharacter() | |
| 127 | + | } | |
| 128 | + | changed := op(m.document) | |
| 129 | + | if changed { | |
| 130 | + | if m.selection.Enabled { | |
| 131 | + | m.selection.Extend(m.navigator.Pos) | |
| 161 | 132 | } | |
| 162 | - | r := cell.Rune | |
| 163 | - | if r == 0 { | |
| 164 | - | r = ' ' | |
| 133 | + | m.adjustViewport() | |
| 134 | + | } | |
| 135 | + | return changed | |
| 136 | + | } | |
| 137 | + | func (m *Mode) Left() bool { return m.move(m.navigator.Left, true) } | |
| 138 | + | func (m *Mode) Right() bool { return m.move(m.navigator.Right, true) } | |
| 139 | + | func (m *Mode) Up() bool { return m.move(m.navigator.Up, false) } | |
| 140 | + | func (m *Mode) Down() bool { return m.move(m.navigator.Down, false) } | |
| 141 | + | func (m *Mode) WordNext() bool { return m.move(m.navigator.WordNext, true) } | |
| 142 | + | func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) } | |
| 143 | + | func (m *Mode) WordEnd() bool { return m.move(m.navigator.WordEnd, true) } | |
| 144 | + | func (m *Mode) MoveRows(delta int) bool { | |
| 145 | + | if delta == 0 { | |
| 146 | + | return false | |
| 147 | + | } | |
| 148 | + | changed := false | |
| 149 | + | op := m.Up | |
| 150 | + | if delta > 0 { | |
| 151 | + | op = m.Down | |
| 152 | + | } | |
| 153 | + | for range abs(delta) { | |
| 154 | + | if !op() { | |
| 155 | + | break | |
| 165 | 156 | } | |
| 166 | - | runes = append(runes, unicode.ToLower(r)) | |
| 167 | - | cellIndexes = append(cellIndexes, x) | |
| 157 | + | changed = true | |
| 168 | 158 | } | |
| 169 | - | return runes, cellIndexes | |
| 159 | + | return changed | |
| 170 | 160 | } | |
| 171 | - | ||
| 172 | - | func lowerRunes(s string) []rune { | |
| 173 | - | runes := []rune(s) | |
| 174 | - | for i, r := range runes { | |
| 175 | - | runes[i] = unicode.ToLower(r) | |
| 161 | + | func (m *Mode) Page(pages int) bool { | |
| 162 | + | if m == nil || m.document == nil { | |
| 163 | + | return false | |
| 176 | 164 | } | |
| 177 | - | return runes | |
| 165 | + | return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false) | |
| 178 | 166 | } | |
| 179 | - | ||
| 180 | - | func (m *Mode) Search(s Snapshot, query string) bool { | |
| 181 | - | return m.SetSearchMatches(s, query, FindMatches(s, query), 0) | |
| 167 | + | func (m *Mode) Top() bool { return m.move(m.navigator.Top, false) } | |
| 168 | + | func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) } | |
| 169 | + | func (m *Mode) AtBottom() bool { | |
| 170 | + | if m == nil || m.document == nil || m.document.Len() == 0 { | |
| 171 | + | return m != nil && m.ViewportTop == 0 | |
| 172 | + | } | |
| 173 | + | return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0) | |
| 182 | 174 | } | |
| 183 | - | ||
| 184 | - | func (m *Mode) SetSearchMatches(s Snapshot, query string, matches []SearchMatch, index int) bool { | |
| 175 | + | func (m *Mode) ToggleLineSelection() { | |
| 176 | + | if m.selection.Enabled { | |
| 177 | + | m.selection = Selection{} | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | m.selection = NewLineSelection(m.navigator.Pos) | |
| 181 | + | } | |
| 182 | + | func (m *Mode) StartCharacterSelection(p Pos) bool { | |
| 183 | + | if m == nil || m.document == nil { | |
| 184 | + | return false | |
| 185 | + | } | |
| 186 | + | p, ok := m.document.Normalize(p) | |
| 187 | + | if !ok { | |
| 188 | + | return false | |
| 189 | + | } | |
| 190 | + | m.navigator.Set(m.document, p) | |
| 191 | + | m.selection = Selection{Anchor: p, Active: p, Granularity: Character, Enabled: true} | |
| 192 | + | m.adjustViewport() | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | func (m *Mode) ExtendCharacterSelection(p Pos) bool { | |
| 196 | + | if m == nil || !m.selection.Enabled { | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | p, ok := m.document.Normalize(p) | |
| 200 | + | if !ok { | |
| 201 | + | return false | |
| 202 | + | } | |
| 203 | + | m.selection.AsCharacter() | |
| 204 | + | m.selection.Extend(p) | |
| 205 | + | m.navigator.Set(m.document, p) | |
| 206 | + | m.adjustViewport() | |
| 207 | + | return true | |
| 208 | + | } | |
| 209 | + | func (m *Mode) SelectWordAt(p Pos) bool { | |
| 210 | + | if m == nil { | |
| 211 | + | return false | |
| 212 | + | } | |
| 213 | + | s, ok := NewWordSelection(m.document, p) | |
| 214 | + | if !ok { | |
| 215 | + | return false | |
| 216 | + | } | |
| 217 | + | m.selection = s | |
| 218 | + | m.navigator.Set(m.document, s.Active) | |
| 219 | + | m.adjustViewport() | |
| 220 | + | return true | |
| 221 | + | } | |
| 222 | + | func (m *Mode) ExtendWordSelection(p Pos) bool { | |
| 223 | + | if m == nil || !m.selection.Enabled { | |
| 224 | + | return false | |
| 225 | + | } | |
| 226 | + | _, end, ok := m.document.WordBounds(p) | |
| 227 | + | if !ok { | |
| 228 | + | return false | |
| 229 | + | } | |
| 230 | + | m.selection.Granularity = Word | |
| 231 | + | m.selection.Extend(end) | |
| 232 | + | m.navigator.Set(m.document, end) | |
| 233 | + | m.adjustViewport() | |
| 234 | + | return true | |
| 235 | + | } | |
| 236 | + | func (m *Mode) Search(query string) bool { | |
| 237 | + | 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 | 244 | if len(matches) == 0 { | |
| 186 | - | m.SearchQuery = strings.TrimSpace(query) | |
| 187 | 245 | m.Searches = nil | |
| 188 | 246 | m.SearchIndex = -1 | |
| 189 | 247 | return false | |
| 190 | 248 | } | |
| 191 | - | m.SearchQuery = strings.TrimSpace(query) | |
| 192 | 249 | 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}) | |
| 201 | 252 | return true | |
| 202 | 253 | } | |
| 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 { | |
| 206 | 256 | return false | |
| 207 | 257 | } | |
| 208 | 258 | m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches) | |
| 209 | 259 | if m.SearchIndex < 0 { | |
| 210 | 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 | 264 | return true | |
| 214 | 265 | } | |
| 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 "" | |
| 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 | - | ||
| 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 { | |
| 243 | 274 | return | |
| 244 | 275 | } | |
| 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 | |
| 254 | 279 | } | |
| 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 | |
| 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) { | |
| 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 | |
| 266 | 290 | } | |
| 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 | |
| 270 | 315 | } | |
| 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) | |
| 275 | 329 | } | |
| 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) | |
| 279 | 336 | } | |
| 280 | - | return lo, hi, true | |
| 337 | + | return rs | |
| 281 | 338 | } | |
| 282 | 339 | ||
| 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) | |
| 287 | 343 | } | |
| 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) | |
| 293 | 350 | } | |
| 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() { | |
| 303 | 353 | src := m.ViewportTop + y | |
| 304 | - | if src >= s.Len() { | |
| 354 | + | if src >= d.Len() { | |
| 305 | 355 | break | |
| 306 | 356 | } | |
| 307 | 357 | 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) | |
| 313 | 362 | } | |
| 314 | 363 | } | |
| 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) | |
| 318 | 369 | } | |
| 319 | 370 | } | |
| 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) | |
| 322 | 374 | } | |
| 323 | 375 | } | |
| 324 | - | style := inverseStyle() | |
| 376 | + | status := inverseStyle() | |
| 325 | 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 | 381 | return frame | |
| 330 | 382 | } | |
| 331 | - | ||
| 332 | 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 | 385 | return SearchMatch{}, false | |
| 335 | 386 | } | |
| 336 | - | match := m.Searches[m.SearchIndex] | |
| 337 | - | return match, match.Row == row | |
| 387 | + | v := m.Searches[m.SearchIndex] | |
| 388 | + | return v, v.Row == row | |
| 338 | 389 | } | |
| 339 | - | ||
| 340 | 390 | func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) { | |
| 341 | 391 | if idx >= len(styles) { | |
| 342 | 392 | return renderer.Style{}, false | |
| 343 | 393 | } | |
| 344 | 394 | return styles[idx], true | |
| 345 | 395 | } | |
| 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()) { | |
| 349 | 398 | dst.Inverse = true | |
| 350 | 399 | return | |
| 351 | 400 | } | |
| 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 | |
| 374 | 402 | } | |
| 375 | - | ||
| 403 | + | func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s } | |
| 376 | 404 | func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) { | |
| 377 | 405 | for i := range row { | |
| 378 | 406 | row[i] = renderer.BlankCell() | |
| 379 | 407 | } | |
| 380 | 408 | text := " [SCROLL] " | |
| 381 | - | if m.Selecting { | |
| 409 | + | if m.selection.Enabled { | |
| 382 | 410 | text = " [SELECT] " | |
| 383 | 411 | } | |
| 384 | 412 | if total > 0 { | |
| 385 | - | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | |
| 413 | + | text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " " | |
| 386 | 414 | } else { | |
| 387 | 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 | 431 | func OSC52(text string) [][]byte { | |
| 407 | 432 | if len([]byte(text)) > OSC52MaxPayloadBytes { | |
| 408 | 433 | return nil | |
| 409 | 434 | } | |
| 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")} | |
| 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 | 437 | func OSC52FromBase64(b64 string) []byte { | |
| 419 | 438 | decoded, err := base64.StdEncoding.DecodeString(b64) | |
| 420 | 439 | if err != nil || len(decoded) > OSC52MaxPayloadBytes { | |
| @@ -422,17 +441,22 @@ func OSC52FromBase64(b64 string) []byte { | |||
| 422 | 441 | } | |
| 423 | 442 | return []byte("\x1b]52;c;" + b64 + "\x07") | |
| 424 | 443 | } | |
| 425 | - | ||
| 426 | 444 | func strconvItoa(n int) string { | |
| 427 | 445 | if n == 0 { | |
| 428 | 446 | return "0" | |
| 429 | 447 | } | |
| 430 | - | var buf [20]byte | |
| 431 | - | i := len(buf) | |
| 448 | + | var b [20]byte | |
| 449 | + | i := len(b) | |
| 432 | 450 | for n > 0 { | |
| 433 | 451 | i-- | |
| 434 | - | buf[i] = byte('0' + n%10) | |
| 452 | + | b[i] = byte('0' + n%10) | |
| 435 | 453 | 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 | } | |
| @@ -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_000 | 13 | const OSC52MaxPayloadBytes = 75_000 | ||
| 19 | 14 | ||||
| 20 | - | // Snapshot is the immutable scrollback-mode document: history followed by the | 15 | + | // 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.HistoryView | 17 | + | history vt.HistoryView |
| 25 | - | screen renderer.Frame | 18 | + | screen renderer.Frame |
| 26 | - | Width int | 19 | + | 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.HistoryView | 23 | 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 nil | 39 | 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 := 0 | 51 | + | i := 0 |
| 74 | stopped := false | 52 | 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 = true | 55 | stopped = true | ||
| 78 | return false | 56 | return false | ||
| 79 | } | 57 | } | ||
| 80 | - | rowIndex++ | 58 | + | i++ |
| 81 | return true | 59 | return true | ||
| 82 | }) | 60 | }) | ||
| 83 | if stopped { | 61 | if stopped { | ||
| 84 | return | 62 | 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 | return | 66 | 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 int | 72 | + | Row, Start, End int |
| 96 | - | Start int | 73 | + | Text string |
| 97 | - | End int | 74 | + | } // 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 int | 80 | ViewportTop int | ||
| 103 | - | Cursor int | |||
| 104 | - | Anchor int | |||
| 105 | - | Selecting bool | |||
| 106 | SearchQuery string | 81 | SearchQuery string | ||
| 107 | Searches []SearchMatch | 82 | Searches []SearchMatch | ||
| 108 | SearchIndex int | 83 | 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 m | 93 | 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 nil | 97 | 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 | - | continue | 107 | + | func (m *Mode) Selection() Selection { |
| 108 | + | if m == nil { | |||
| 109 | + | return Selection{} | |||
| 110 | + | } | |||
| 111 | + | return m.selection | |||
| 112 | + | } | |||
| 113 | + | func (m *Mode) SetPosition(p Pos) bool { | |||
| 114 | + | if m == nil { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | changed := m.navigator.Set(m.document, p) | |||
| 118 | + | m.adjustViewport() | |||
| 119 | + | return changed | |||
| 120 | + | } | |||
| 121 | + | func (m *Mode) move(op func(*Document) bool, stream bool) bool { | |||
| 122 | + | if m == nil || m.document == nil { | |||
| 123 | + | return false | |||
| 124 | + | } | |||
| 125 | + | if stream && m.selection.Enabled && m.selection.Granularity == Line { | |||
| 126 | + | m.selection.AsCharacter() | |||
| 127 | + | } | |||
| 128 | + | changed := op(m.document) | |||
| 129 | + | if changed { | |||
| 130 | + | if m.selection.Enabled { | |||
| 131 | + | m.selection.Extend(m.navigator.Pos) | |||
| 161 | } | 132 | } | ||
| 162 | - | r := cell.Rune | 133 | + | 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, cellIndexes | 159 | + | 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 runes | 165 | + | 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 = nil | 245 | m.Searches = nil | ||
| 188 | m.SearchIndex = -1 | 246 | m.SearchIndex = -1 | ||
| 189 | return false | 247 | 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 = 0 | 251 | + | 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 true | 252 | 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 false | 256 | 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 true | 264 | return true | ||
| 214 | } | 265 | } | ||
| 215 | - | 266 | + | func (m *Mode) SelectedText() string { | |
| 216 | - | // SetCursor moves the visual cursor to row, clamping it to the snapshot and | 267 | + | 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 | return | 274 | return | ||
| 244 | } | 275 | } | ||
| 245 | - | m.Selecting = true | 276 | + | rows := max(m.document.Height(), 1) |
| 246 | - | m.Anchor = m.Cursor | 277 | + | 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) + 1 | 281 | + | 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, 0 | 289 | + | 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, false | 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) | |||
| 275 | } | 329 | } | ||
| 276 | - | lo, hi := m.Anchor, m.Cursor | 330 | + | return rs, is |
| 277 | - | if lo > hi { | 331 | + | } |
| 278 | - | lo, hi = hi, lo | 332 | + | 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, true | 337 | + | 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 + y | 353 | src := m.ViewportTop + y | ||
| 304 | - | if src >= s.Len() { | 354 | + | if src >= d.Len() { |
| 305 | break | 355 | 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 <= hi | 359 | + | 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 frame | 381 | 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{}, false | 385 | return SearchMatch{}, false | ||
| 335 | } | 386 | } | ||
| 336 | - | match := m.Searches[m.SearchIndex] | 387 | + | v := m.Searches[m.SearchIndex] |
| 337 | - | return match, match.Row == row | 388 | + | 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{}, false | 392 | return renderer.Style{}, false | ||
| 343 | } | 393 | } | ||
| 344 | return styles[idx], true | 394 | 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 = true | 398 | dst.Inverse = true | ||
| 350 | return | 399 | return | ||
| 351 | } | 400 | } | ||
| 352 | - | *dst = selection | 401 | + | *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 nil | 433 | 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]byte | 448 | + | 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 /= 10 | 453 | 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 ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | - | "github.com/bnema/vev/pkg/vt" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | var benchmarkSnapshotSink Snapshot | |
| 15 | - | ||
| 16 | 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 | 16 | 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}) | |
| 46 | 18 | } | |
| 47 | 19 | return cells | |
| 48 | 20 | } | |
| 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 | |
| 502 | 90 | }{ | |
| 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])) | |
| 514 | 105 | }) | |
| 515 | 106 | } | |
| 516 | 107 | } | |
| 517 | 108 | ||
| 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)) | |
| 641 | 113 | } | |
| @@ -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 cells | 19 | 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 string | 24 | + | rows[i] = row(lines[i]) |
| 53 | - | lines []string | 25 | + | } |
| 54 | - | height int | 26 | + | return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators)) |
| 55 | - | ops func(*Mode, Snapshot) | 27 | + | } |
| 56 | - | wantCursor int | 28 | + | |
| 57 | - | wantTop int | 29 | + | 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 { | |||
| 31 | 31 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 32 | 32 | rt := ac.overlays | |
| 33 | 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 | 35 | rt.copyMu.Unlock() | |
| 36 | 36 | return | |
| 37 | 37 | } | |
| 38 | - | document := rt.copySnapshot | |
| 39 | - | if delta > 0 && rt.copyMode.AtBottom(*document) { | |
| 38 | + | if delta > 0 && rt.copyMode.AtBottom() { | |
| 40 | 39 | rt.clearCopyModeLocked() | |
| 41 | 40 | rt.copyMu.Unlock() | |
| 42 | 41 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 43 | 42 | return | |
| 44 | 43 | } | |
| 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() | |
| 47 | 46 | if exit { | |
| 48 | 47 | rt.clearCopyModeLocked() | |
| 49 | 48 | } | |
| 50 | 49 | rt.copyMu.Unlock() | |
| 51 | - | ||
| 52 | 50 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 53 | 51 | } | |
| 54 | - | ||
| 55 | 52 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 56 | 53 | tb := sess.activeTab() | |
| 57 | 54 | if tb == nil { | |
| @@ -63,23 +60,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 63 | 60 | if p == nil { | |
| 64 | 61 | 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 | 63 | p.mu.Lock() | |
| 69 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 64 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 70 | 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 | 69 | return | |
| 73 | 70 | } | |
| 74 | 71 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 75 | 72 | } | |
| 76 | 73 | ||
| 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 { | |
| 83 | 78 | return false | |
| 84 | 79 | } | |
| 85 | 80 | mode := scopy.NewMode(document) | |
| @@ -92,7 +87,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 92 | 87 | rt.copyPending = nil | |
| 93 | 88 | rt.copyMode = nil | |
| 94 | 89 | rt.copyCandidate = mode | |
| 95 | - | rt.copySnapshot = &document | |
| 90 | + | rt.copyDocument = document | |
| 96 | 91 | rt.copyPane = p | |
| 97 | 92 | rt.copySearch = nil | |
| 98 | 93 | rt.copySearchPending = nil | |
| @@ -100,18 +95,15 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 100 | 95 | rt.copyDragging = false | |
| 101 | 96 | rt.normalMousePressValid = false | |
| 102 | 97 | rt.copyMu.Unlock() | |
| 103 | - | ||
| 104 | - | active := sess.activeTab() | |
| 105 | - | valid := active == tb | |
| 98 | + | valid := sess.activeTab() == tb | |
| 106 | 99 | if valid { | |
| 107 | 100 | tb.mu.Lock() | |
| 108 | 101 | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | |
| 109 | 102 | tb.mu.Unlock() | |
| 110 | 103 | } | |
| 111 | - | ||
| 112 | 104 | rt.copyMu.Lock() | |
| 113 | 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 | 107 | return false | |
| 116 | 108 | } | |
| 117 | 109 | if !valid { | |
| @@ -120,21 +112,25 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 120 | 112 | } | |
| 121 | 113 | rt.copyCandidate = nil | |
| 122 | 114 | rt.copyMode = mode | |
| 115 | + | if activate != nil { | |
| 116 | + | activate(rt, mode) | |
| 117 | + | } | |
| 123 | 118 | return true | |
| 124 | 119 | } | |
| 125 | 120 | ||
| 121 | + | // copyMouse remains row-compatible until centralized pointer mapping supplies columns. | |
| 126 | 122 | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 127 | 123 | rt := ac.overlays | |
| 128 | 124 | if ev.Button != mouse.Left { | |
| 129 | 125 | return | |
| 130 | 126 | } | |
| 131 | 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 | 129 | rt.copyMu.Unlock() | |
| 134 | 130 | return | |
| 135 | 131 | } | |
| 136 | - | document := rt.copySnapshot | |
| 137 | - | if ev.Row >= document.Height { | |
| 132 | + | document := rt.copyDocument | |
| 133 | + | if ev.Row >= document.Height() { | |
| 138 | 134 | if ev.Type == mouse.Press { | |
| 139 | 135 | rt.copyPressRowValid = false | |
| 140 | 136 | rt.copyDragging = false | |
| @@ -142,30 +138,30 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 142 | 138 | rt.copyMu.Unlock() | |
| 143 | 139 | return | |
| 144 | 140 | } | |
| 145 | - | absRow := rt.copyMode.ViewportTop + ev.Row | |
| 141 | + | pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0} | |
| 146 | 142 | changed := false | |
| 147 | 143 | switch ev.Type { | |
| 148 | 144 | 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 | |
| 151 | 149 | rt.copyPressRowValid = true | |
| 152 | 150 | rt.copyDragging = false | |
| 153 | - | changed = true | |
| 154 | 151 | 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 | |
| 161 | 159 | } | |
| 162 | - | rt.copyMode.ExtendTo(*document, absRow) | |
| 163 | - | changed = true | |
| 164 | 160 | case mouse.Release: | |
| 165 | - | // Button release intentionally has no visual effect. | |
| 161 | + | rt.copyPressRowValid = false | |
| 162 | + | rt.copyDragging = false | |
| 166 | 163 | } | |
| 167 | 164 | rt.copyMu.Unlock() | |
| 168 | - | ||
| 169 | 165 | if changed { | |
| 170 | 166 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 171 | 167 | } | |
| @@ -178,7 +174,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 178 | 174 | return | |
| 179 | 175 | } | |
| 180 | 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 | 178 | rt.copyPending = nil | |
| 183 | 179 | d.stopCopyPendingTimerLocked(ac) | |
| 184 | 180 | rt.copyMu.Unlock() | |
| @@ -186,61 +182,71 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 186 | 182 | } | |
| 187 | 183 | if len(rt.copyPending) > 0 { | |
| 188 | 184 | 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...) | |
| 193 | 186 | rt.copyPending = nil | |
| 194 | 187 | } | |
| 195 | - | document := rt.copySnapshot | |
| 196 | 188 | if rt.copySearch != nil { | |
| 197 | - | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data) | |
| 189 | + | changed, close, accepted := d.routeCopySearchInputLocked(rt, data) | |
| 198 | 190 | rt.copyMu.Unlock() | |
| 199 | - | if changed || closeSearch || accepted { | |
| 191 | + | if changed || close || accepted { | |
| 200 | 192 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 201 | 193 | } | |
| 202 | 194 | return | |
| 203 | 195 | } | |
| 204 | - | changed := false | |
| 205 | - | copyOut := false | |
| 206 | - | exit := false | |
| 196 | + | changed, copyOut, exit := false, false, false | |
| 207 | 197 | for i := 0; i < len(data); i++ { | |
| 208 | 198 | switch data[i] { | |
| 199 | + | case 'h': | |
| 200 | + | rt.copyMode.Left() | |
| 201 | + | changed = true | |
| 209 | 202 | case 'j': | |
| 210 | - | rt.copyMode.Move(*document, 1) | |
| 203 | + | rt.copyMode.Down() | |
| 211 | 204 | changed = true | |
| 212 | 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 | 219 | changed = true | |
| 215 | 220 | case 'g': | |
| 216 | - | rt.copyMode.Top(*document) | |
| 221 | + | rt.copyMode.Top() | |
| 217 | 222 | changed = true | |
| 218 | 223 | case 'G': | |
| 219 | - | rt.copyMode.Bottom(*document) | |
| 224 | + | rt.copyMode.Bottom() | |
| 220 | 225 | changed = true | |
| 221 | 226 | case ' ', 'v': | |
| 222 | - | rt.copyMode.ToggleSelection() | |
| 227 | + | rt.copyMode.ToggleLineSelection() | |
| 223 | 228 | changed = true | |
| 224 | 229 | case '/': | |
| 225 | - | rt.copySearch = visualsearch.New(*document) | |
| 230 | + | rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot()) | |
| 226 | 231 | rt.copySearchPending = nil | |
| 227 | 232 | changed = true | |
| 228 | 233 | 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 | |
| 231 | 237 | } | |
| 232 | 238 | i = len(data) | |
| 233 | 239 | case 'n': | |
| 234 | - | changed = rt.copyMode.NextSearchMatch(*document, 1) || changed | |
| 240 | + | changed = rt.copyMode.NextSearchMatch(1) || changed | |
| 235 | 241 | case 'N': | |
| 236 | - | changed = rt.copyMode.NextSearchMatch(*document, -1) || changed | |
| 242 | + | changed = rt.copyMode.NextSearchMatch(-1) || changed | |
| 237 | 243 | case '\r', '\n', 'y': | |
| 238 | 244 | copyOut = true | |
| 239 | 245 | exit = true | |
| 240 | 246 | case 'q', 0x03, 0x1b: | |
| 241 | 247 | if data[i] == 0x1b { | |
| 242 | 248 | tail := data[i:] | |
| 243 | - | consumed, ok := routeCopyEscape(rt.copyMode, *document, tail) | |
| 249 | + | consumed, ok := routeCopyEscape(rt.copyMode, tail) | |
| 244 | 250 | if ok { | |
| 245 | 251 | i += consumed - 1 | |
| 246 | 252 | changed = true | |
| @@ -260,14 +266,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 260 | 266 | } | |
| 261 | 267 | text := "" | |
| 262 | 268 | if copyOut { | |
| 263 | - | text = rt.copyMode.SelectedText(*document) | |
| 269 | + | text = rt.copyMode.SelectedText() | |
| 264 | 270 | } | |
| 265 | 271 | if exit { | |
| 266 | 272 | d.stopCopyPendingTimerLocked(ac) | |
| 267 | 273 | rt.clearCopyModeLocked() | |
| 268 | 274 | } | |
| 269 | 275 | rt.copyMu.Unlock() | |
| 270 | - | ||
| 271 | 276 | if copyOut && text != "" { | |
| 272 | 277 | chunks := scopy.OSC52(text) | |
| 273 | 278 | for _, chunk := range chunks { | |
| @@ -285,63 +290,34 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 285 | 290 | } | |
| 286 | 291 | rt.copyMu.Unlock() | |
| 287 | 292 | } | |
| 288 | - | if exit { | |
| 289 | - | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 290 | - | return | |
| 291 | - | } | |
| 292 | - | if changed { | |
| 293 | + | if exit || changed { | |
| 293 | 294 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 294 | 295 | } | |
| 295 | 296 | } | |
| 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 }}) | |
| 324 | 304 | if changed && !closeSearch { | |
| 325 | - | d.previewCopySearchSelectionLocked(rt, snap) | |
| 305 | + | d.previewCopySearchSelectionLocked(rt) | |
| 326 | 306 | } | |
| 327 | 307 | if closeSearch { | |
| 328 | 308 | rt.copySearch = nil | |
| 329 | 309 | rt.copySearchPending = nil | |
| 330 | 310 | } | |
| 331 | - | return changed, closeSearch, accepted | |
| 311 | + | return | |
| 332 | 312 | } | |
| 333 | - | ||
| 334 | - | func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime, snap scopy.Snapshot) { | |
| 313 | + | func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) { | |
| 335 | 314 | if rt == nil || rt.copyMode == nil || rt.copySearch == nil { | |
| 336 | 315 | return | |
| 337 | 316 | } | |
| 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}) | |
| 341 | 319 | } | |
| 342 | - | rt.copyMode.SetCursor(snap, match.Row) | |
| 343 | 320 | } | |
| 344 | - | ||
| 345 | 321 | func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |
| 346 | 322 | rt := ac.overlays | |
| 347 | 323 | mode := rt.copyMode | |
| @@ -361,53 +337,44 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||
| 361 | 337 | } | |
| 362 | 338 | rt.clearCopyModeLocked() | |
| 363 | 339 | rt.copyMu.Unlock() | |
| 364 | - | ||
| 365 | 340 | if sess := ac.currentSession(); sess != nil { | |
| 366 | 341 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 367 | 342 | } | |
| 368 | 343 | }) | |
| 369 | 344 | } | |
| 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) { | |
| 377 | 347 | if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') { | |
| 378 | 348 | switch data[2] { | |
| 379 | 349 | case 'A': | |
| 380 | - | m.Move(snap, -1) | |
| 350 | + | m.Up() | |
| 381 | 351 | return 3, true | |
| 382 | 352 | case 'B': | |
| 383 | - | m.Move(snap, 1) | |
| 353 | + | m.Down() | |
| 384 | 354 | return 3, true | |
| 385 | 355 | } | |
| 386 | 356 | } | |
| 387 | 357 | if len(data) >= 4 && data[1] == '[' && data[3] == '~' { | |
| 388 | 358 | switch data[2] { | |
| 389 | 359 | case '5': | |
| 390 | - | m.Page(snap, -1) | |
| 360 | + | m.Page(-1) | |
| 391 | 361 | return 4, true | |
| 392 | 362 | case '6': | |
| 393 | - | m.Page(snap, 1) | |
| 363 | + | m.Page(1) | |
| 394 | 364 | return 4, true | |
| 395 | 365 | } | |
| 396 | 366 | } | |
| 397 | 367 | return 0, false | |
| 398 | 368 | } | |
| 399 | - | ||
| 400 | 369 | 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') | |
| 403 | 371 | } | |
| 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 { | |
| 407 | 374 | return frame, nil | |
| 408 | 375 | } | |
| 409 | 376 | styles := newThemeStyles(bars.theme) | |
| 410 | - | copyFrame := mode.Render(*document, styles.copyStatus, styles.selection) | |
| 377 | + | copyFrame := mode.Render(styles.copyStatus, styles.selection) | |
| 411 | 378 | bodyRows := max(copyFrame.Height-1, 0) | |
| 412 | 379 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| 413 | 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 | 388 | 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() | |
| 426 | 391 | } | |
| 427 | 392 | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) | |
| 428 | 393 | 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.overlays | 32 | 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 | return | 36 | return | ||
| 37 | } | 37 | } | ||
| 38 | - | document := rt.copySnapshot | 38 | + | 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 | return | 42 | 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 | return | 61 | 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 | return | 69 | 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 captured | 74 | + | // publishCopyMode serializes overlay publication after pane membership is |
| 78 | - | // target without overlapping copyMu with session or tab locks, then atomically | 75 | + | // revalidated. Document is the sole immutable copy payload. |
| 79 | - | // activates that same candidate. A tab transition, pane close, or newer | 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 { |
| 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 false | 78 | 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 = nil | 87 | rt.copyPending = nil | ||
| 93 | rt.copyMode = nil | 88 | rt.copyMode = nil | ||
| 94 | rt.copyCandidate = mode | 89 | rt.copyCandidate = mode | ||
| 95 | - | rt.copySnapshot = &document | 90 | + | rt.copyDocument = document |
| 96 | rt.copyPane = p | 91 | rt.copyPane = p | ||
| 97 | rt.copySearch = nil | 92 | rt.copySearch = nil | ||
| 98 | rt.copySearchPending = nil | 93 | rt.copySearchPending = nil | ||
| @@ -100,18 +95,15 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 100 | rt.copyDragging = false | 95 | rt.copyDragging = false | ||
| 101 | rt.normalMousePressValid = false | 96 | 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 false | 107 | 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 = nil | 113 | rt.copyCandidate = nil | ||
| 122 | rt.copyMode = mode | 114 | rt.copyMode = mode | ||
| 115 | + | if activate != nil { | |||
| 116 | + | activate(rt, mode) | |||
| 117 | + | } | |||
| 123 | return true | 118 | 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.overlays | 123 | rt := ac.overlays | ||
| 128 | if ev.Button != mouse.Left { | 124 | if ev.Button != mouse.Left { | ||
| 129 | return | 125 | 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 | return | 130 | return | ||
| 135 | } | 131 | } | ||
| 136 | - | document := rt.copySnapshot | 132 | + | 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 = false | 135 | rt.copyPressRowValid = false | ||
| 140 | rt.copyDragging = false | 136 | 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 | return | 139 | return | ||
| 144 | } | 140 | } | ||
| 145 | - | absRow := rt.copyMode.ViewportTop + ev.Row | 141 | + | pos := scopy.Pos{Row: rt.copyMode.ViewportTop + ev.Row, Col: 0} |
| 146 | changed := false | 142 | 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.Cursor | 146 | + | changed = true |
| 147 | + | } | |||
| 148 | + | rt.copyPressRow = rt.copyMode.Cursor().Row | |||
| 151 | rt.copyPressRowValid = true | 149 | rt.copyPressRowValid = true | ||
| 152 | rt.copyDragging = false | 150 | rt.copyDragging = false | ||
| 153 | - | changed = true | |||
| 154 | case mouse.Motion: | 151 | case mouse.Motion: | ||
| 155 | - | if !rt.copyPressRowValid { | 152 | + | if rt.copyPressRowValid { |
| 156 | - | break | 153 | + | 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 = true | 157 | + | } |
| 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 | return | 174 | 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 = nil | 178 | 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 = nil | 186 | 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 | return | 194 | return | ||
| 203 | } | 195 | } | ||
| 204 | - | changed := false | 196 | + | 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 = true | 204 | 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 = true | 219 | changed = true | ||
| 215 | case 'g': | 220 | case 'g': | ||
| 216 | - | rt.copyMode.Top(*document) | 221 | + | rt.copyMode.Top() |
| 217 | changed = true | 222 | changed = true | ||
| 218 | case 'G': | 223 | case 'G': | ||
| 219 | - | rt.copyMode.Bottom(*document) | 224 | + | rt.copyMode.Bottom() |
| 220 | changed = true | 225 | changed = true | ||
| 221 | case ' ', 'v': | 226 | case ' ', 'v': | ||
| 222 | - | rt.copyMode.ToggleSelection() | 227 | + | rt.copyMode.ToggleLineSelection() |
| 223 | changed = true | 228 | changed = true | ||
| 224 | case '/': | 229 | case '/': | ||
| 225 | - | rt.copySearch = visualsearch.New(*document) | 230 | + | rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot()) |
| 226 | rt.copySearchPending = nil | 231 | rt.copySearchPending = nil | ||
| 227 | changed = true | 232 | 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 || accepted | 235 | + | 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) || changed | 240 | + | changed = rt.copyMode.NextSearchMatch(1) || changed |
| 235 | case 'N': | 241 | case 'N': | ||
| 236 | - | changed = rt.copyMode.NextSearchMatch(*document, -1) || changed | 242 | + | changed = rt.copyMode.NextSearchMatch(-1) || changed |
| 237 | case '\r', '\n', 'y': | 243 | case '\r', '\n', 'y': | ||
| 238 | copyOut = true | 244 | copyOut = true | ||
| 239 | exit = true | 245 | 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 - 1 | 251 | i += consumed - 1 | ||
| 246 | changed = true | 252 | 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 = true | 302 | + | } |
| 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 = nil | 308 | rt.copySearch = nil | ||
| 329 | rt.copySearchPending = nil | 309 | rt.copySearchPending = nil | ||
| 330 | } | 310 | } | ||
| 331 | - | return changed, closeSearch, accepted | 311 | + | 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 | return | 315 | 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.overlays | 322 | rt := ac.overlays | ||
| 347 | mode := rt.copyMode | 323 | 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, true | 351 | return 3, true | ||
| 382 | case 'B': | 352 | case 'B': | ||
| 383 | - | m.Move(snap, 1) | 353 | + | m.Down() |
| 384 | return 3, true | 354 | 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, true | 361 | return 4, true | ||
| 392 | case '6': | 362 | case '6': | ||
| 393 | - | m.Page(snap, 1) | 363 | + | m.Page(1) |
| 394 | return 4, true | 364 | return 4, true | ||
| 395 | } | 365 | } | ||
| 396 | } | 366 | } | ||
| 397 | return 0, false | 367 | 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, nil | 374 | 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 - 1 | 388 | 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) { | |||
| 54 | 54 | sess.client = ac | |
| 55 | 55 | ac.setSession(sess) | |
| 56 | 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 | 58 | closing.mu.Unlock() | |
| 59 | 59 | ac.overlays.copyMu.Lock() | |
| 60 | 60 | ac.overlays.copyPane = closing | |
| 61 | - | ac.overlays.copySnapshot = &document | |
| 61 | + | ac.overlays.copyDocument = document | |
| 62 | 62 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 63 | 63 | ac.overlays.copyMu.Unlock() | |
| 64 | 64 | ||
| @@ -94,7 +94,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 94 | 94 | tb := sess.activeTab() | |
| 95 | 95 | p := tb.focusedPane() | |
| 96 | 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 | 98 | p.mu.Unlock() | |
| 99 | 99 | ||
| 100 | 100 | // Hold session validation after publication and make the captured target | |
| @@ -104,12 +104,12 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 104 | 104 | sess.active = 1 | |
| 105 | 105 | result := make(chan bool, 1) | |
| 106 | 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 | 109 | published := assert.Eventually(t, func() bool { | |
| 110 | 110 | ac.overlays.copyMu.Lock() | |
| 111 | 111 | 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 | |
| 113 | 113 | }, time.Second, time.Millisecond) | |
| 114 | 114 | if !published { | |
| 115 | 115 | sess.mu.Unlock() | |
| @@ -157,11 +157,11 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 157 | 157 | tb := sess.activeTab() | |
| 158 | 158 | p := tb.focusedPane() | |
| 159 | 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 | 161 | p.mu.Unlock() | |
| 162 | 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 | 165 | require.False(t, ac.overlays.copyActive()) | |
| 166 | 166 | require.Nil(t, copyTargetPane(ac.overlays)) | |
| 167 | 167 | }) | |
| @@ -54,11 +54,11 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 54 | sess.client = ac | 54 | 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 = closing | 60 | ac.overlays.copyPane = closing | ||
| 61 | - | ac.overlays.copySnapshot = &document | 61 | + | 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 target | 100 | // Hold session validation after publication and make the captured target | ||
| @@ -104,12 +104,12 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||||
| 104 | sess.active = 1 | 104 | 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 != nil | 112 | + | 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) { | |||
| 114 | 114 | pane.mu.Lock() | |
| 115 | 115 | snap := scopy.NewSnapshot(pane.history, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | - | mode := scopy.NewMode(snap) | |
| 117 | + | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | 118 | bars := barState{status: sess.statusSegments(true)} | |
| 119 | 119 | ||
| 120 | 120 | base := renderer.NewFrame(80, 25) | |
| @@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 133 | 133 | go func() { | |
| 134 | 134 | defer wg.Done() | |
| 135 | 135 | for range 200 { | |
| 136 | - | _, _ = composeCopyClientFrame(mode, &snap, target, base, bars) | |
| 136 | + | _, _ = composeCopyClientFrame(mode, target, base, bars) | |
| 137 | 137 | } | |
| 138 | 138 | }() | |
| 139 | 139 | wg.Wait() | |
| @@ -148,7 +148,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 148 | 148 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 149 | 149 | tb.focusedPane().screen.Write([]byte("live")) | |
| 150 | 150 | snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame) | |
| 151 | - | mode := scopy.NewMode(snap) | |
| 151 | + | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 152 | 152 | ||
| 153 | 153 | bars := barState{status: sess.statusSegments(true)} | |
| 154 | 154 | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| @@ -160,7 +160,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 160 | 160 | }}, | |
| 161 | 161 | bars: bars, | |
| 162 | 162 | }, composeCacheInput{}).frame | |
| 163 | - | frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 163 | + | frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 164 | 164 | ||
| 165 | 165 | require.Equal(t, 80, frame.Width) | |
| 166 | 166 | require.Equal(t, 25, frame.Height) | |
| @@ -220,7 +220,7 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) { | |||
| 220 | 220 | ||
| 221 | 221 | require.Nil(t, ac.overlays.copySearch) | |
| 222 | 222 | require.NotNil(t, ac.overlays.copyMode) | |
| 223 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor) | |
| 223 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row) | |
| 224 | 224 | select { | |
| 225 | 225 | case got := <-writes: | |
| 226 | 226 | t.Fatalf("batched visual search input forwarded to PTY: %q", got) | |
| @@ -257,14 +257,14 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) { | |||
| 257 | 257 | d.handleInput(sess, ac, []byte("\r")) | |
| 258 | 258 | awaitFrame(t, sends, ports.MsgOutput) | |
| 259 | 259 | require.Nil(t, ac.overlays.copySearch) | |
| 260 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor) | |
| 260 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row) | |
| 261 | 261 | ||
| 262 | 262 | d.handleInput(sess, ac, []byte("n")) | |
| 263 | 263 | awaitFrame(t, sends, ports.MsgOutput) | |
| 264 | - | require.Equal(t, 1, ac.overlays.copyMode.Cursor) | |
| 264 | + | require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row) | |
| 265 | 265 | d.handleInput(sess, ac, []byte("N")) | |
| 266 | 266 | awaitFrame(t, sends, ports.MsgOutput) | |
| 267 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor) | |
| 267 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row) | |
| 268 | 268 | ||
| 269 | 269 | select { | |
| 270 | 270 | case got := <-writes: | |
| @@ -289,12 +289,12 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) { | |||
| 289 | 289 | d.handleInput(sess, ac, []byte("alpha")) | |
| 290 | 290 | awaitFrame(t, sends, ports.MsgOutput) | |
| 291 | 291 | require.NotNil(t, ac.overlays.copySearch) | |
| 292 | - | require.Equal(t, 0, ac.overlays.copyMode.Cursor, "typing a query previews the selected first result behind the modal") | |
| 292 | + | require.Equal(t, 0, ac.overlays.copyMode.Cursor().Row, "typing a query previews the selected first result behind the modal") | |
| 293 | 293 | ||
| 294 | 294 | d.handleInput(sess, ac, []byte{0x0e}) | |
| 295 | 295 | awaitFrame(t, sends, ports.MsgOutput) | |
| 296 | 296 | require.NotNil(t, ac.overlays.copySearch) | |
| 297 | - | require.Equal(t, 1, ac.overlays.copyMode.Cursor, "moving modal selection previews that result without Enter") | |
| 297 | + | require.Equal(t, 1, ac.overlays.copyMode.Cursor().Row, "moving modal selection previews that result without Enter") | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | 300 | func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |
| @@ -310,11 +310,11 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) { | |||
| 310 | 310 | d.handleInput(sess, ac, []byte("/alpha")) | |
| 311 | 311 | awaitFrame(t, sends, ports.MsgOutput) | |
| 312 | 312 | require.NotNil(t, ac.overlays.copySearch) | |
| 313 | - | cursor := ac.overlays.copyMode.Cursor | |
| 313 | + | cursor := ac.overlays.copyMode.Cursor().Row | |
| 314 | 314 | ||
| 315 | 315 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 316 | 316 | require.NotNil(t, ac.overlays.copySearch) | |
| 317 | - | require.Equal(t, cursor, ac.overlays.copyMode.Cursor) | |
| 317 | + | require.Equal(t, cursor, ac.overlays.copyMode.Cursor().Row) | |
| 318 | 318 | ||
| 319 | 319 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 320 | 320 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -517,7 +517,7 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 517 | 517 | } | |
| 518 | 518 | ||
| 519 | 519 | require.NotNil(t, ac.overlays.copyMode) | |
| 520 | - | require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor) | |
| 520 | + | require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor().Row) | |
| 521 | 521 | }) | |
| 522 | 522 | } | |
| 523 | 523 | } | |
| @@ -790,11 +790,11 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 790 | 790 | ||
| 791 | 791 | d.enterCopyMode(sess, ac) | |
| 792 | 792 | awaitFrame(t, sends, ports.MsgOutput) | |
| 793 | - | require.Equal(t, total-1, ac.overlays.copyMode.Cursor) | |
| 793 | + | require.Equal(t, total-1, ac.overlays.copyMode.Cursor().Row) | |
| 794 | 794 | ||
| 795 | 795 | d.copyWheel(sess, ac, -3) | |
| 796 | 796 | awaitFrame(t, sends, ports.MsgOutput) | |
| 797 | - | require.Equal(t, total-4, ac.overlays.copyMode.Cursor) | |
| 797 | + | require.Equal(t, total-4, ac.overlays.copyMode.Cursor().Row) | |
| 798 | 798 | ||
| 799 | 799 | d.copyWheel(sess, ac, 3) | |
| 800 | 800 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -821,15 +821,15 @@ func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 821 | 821 | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3) | |
| 822 | 822 | d.handleInput(sess, ac, []byte(press)) | |
| 823 | 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 | 826 | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4) | |
| 827 | 827 | d.handleInput(sess, ac, []byte(motion)) | |
| 828 | 828 | 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) | |
| 833 | 833 | } | |
| 834 | 834 | ||
| 835 | 835 | 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{}).frame | 162 | }, composeCacheInput{}).frame | ||
| 163 | - | frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | 163 | + | frame, damage := composeCopyClientFrame(mode, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) |
| 164 | 164 | ||||
| 165 | 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.Cursor | 313 | + | 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, | |||
| 249 | 249 | ||
| 250 | 250 | // The immutable snapshot is the only live-pane access needed for drag entry. | |
| 251 | 251 | p.mu.Lock() | |
| 252 | - | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 252 | + | snapshot := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 253 | 253 | p.mu.Unlock() | |
| 254 | + | cfg := d.currentCopyConfig() | |
| 255 | + | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| 254 | 256 | tb := sess.activeTab() | |
| 255 | 257 | 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) { | |
| 259 | 262 | return | |
| 260 | 263 | } | |
| 261 | 264 | 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 { | |
| 265 | 267 | rt.copyPressRow = pressTop + pressRow | |
| 266 | 268 | rt.copyPressRowValid = true | |
| 267 | 269 | 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 | return | 262 | return | ||
| 260 | } | 263 | } | ||
| 261 | rt.copyMu.Lock() | 264 | rt.copyMu.Lock() | ||
| 262 | - | // Publication is revalidated before this state is installed, and no | 265 | + | // 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 + pressRow | 267 | rt.copyPressRow = pressTop + pressRow | ||
| 266 | rt.copyPressRowValid = true | 268 | rt.copyPressRowValid = true | ||
| 267 | rt.copyDragging = true | 269 | rt.copyDragging = true | ||
M internal/usecase/daemon/input_test.go +37 -13
| @@ -844,7 +844,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||
| 844 | 844 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 845 | 845 | data := mustOutputData(t, sends) | |
| 846 | 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 | 848 | require.Contains(t, string(data), "[SCROLL]") | |
| 849 | 849 | ||
| 850 | 850 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| @@ -950,8 +950,12 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 950 | 950 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 951 | 951 | mustOutputData(t, sends) | |
| 952 | 952 | 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 | + | } | |
| 955 | 959 | require.Equal(t, 0, lo) | |
| 956 | 960 | require.Equal(t, 2, hi) | |
| 957 | 961 | } | |
| @@ -968,8 +972,12 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 968 | 972 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 969 | 973 | mustOutputData(t, sends) | |
| 970 | 974 | 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 | + | } | |
| 973 | 981 | require.Equal(t, 0, lo) | |
| 974 | 982 | require.Equal(t, 1, hi) | |
| 975 | 983 | ||
| @@ -1039,8 +1047,12 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 1039 | 1047 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 1040 | 1048 | mustOutputData(t, sends) | |
| 1041 | 1049 | 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 | + | } | |
| 1044 | 1056 | require.Equal(t, 0, lo) | |
| 1045 | 1057 | require.Equal(t, 1, hi) | |
| 1046 | 1058 | ||
| @@ -1053,8 +1065,12 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 1053 | 1065 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 1054 | 1066 | ||
| 1055 | 1067 | 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 | + | } | |
| 1058 | 1074 | require.Equal(t, 0, lo, "anchor must not have moved") | |
| 1059 | 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 | 1103 | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 1088 | 1104 | ||
| 1089 | 1105 | 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 | + | } | |
| 1092 | 1112 | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | |
| 1093 | 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 | 1222 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M")) | |
| 1203 | 1223 | mustOutputData(t, sends) | |
| 1204 | 1224 | ||
| 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 | + | } | |
| 1207 | 1231 | require.Equal(t, 0, lo) | |
| 1208 | 1232 | require.Equal(t, 9, hi) | |
| 1209 | 1233 | } | |
| @@ -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 { | |||
| 36 | 36 | copyMu sync.Mutex | |
| 37 | 37 | copyMode *scopy.Mode | |
| 38 | 38 | copyCandidate *scopy.Mode | |
| 39 | - | copySnapshot *scopy.Snapshot | |
| 39 | + | copyDocument *scopy.Document | |
| 40 | 40 | copyPane *pane | |
| 41 | 41 | copyPending []byte | |
| 42 | 42 | copyESC pendingByteTimer | |
| @@ -110,7 +110,7 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||
| 110 | 110 | func (rt *overlayRuntime) clearCopyModeLocked() { | |
| 111 | 111 | rt.copyMode = nil | |
| 112 | 112 | rt.copyCandidate = nil | |
| 113 | - | rt.copySnapshot = nil | |
| 113 | + | rt.copyDocument = nil | |
| 114 | 114 | rt.copyPane = nil | |
| 115 | 115 | rt.copySearch = nil | |
| 116 | 116 | rt.copySearchPending = nil | |
| @@ -158,7 +158,7 @@ type overlayRenderSnapshot struct { | |||
| 158 | 158 | ||
| 159 | 159 | copyActive bool | |
| 160 | 160 | copyMode *scopy.Mode | |
| 161 | - | copySnapshot *scopy.Snapshot | |
| 161 | + | copyDocument *scopy.Document | |
| 162 | 162 | copyPane *pane | |
| 163 | 163 | copySearchModel *visualsearch.Model | |
| 164 | 164 | copyFeedback string | |
| @@ -195,7 +195,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 195 | 195 | rt.copyMu.Lock() | |
| 196 | 196 | snap.copyActive = rt.copyMode != nil | |
| 197 | 197 | snap.copyPane = rt.copyPane | |
| 198 | - | snap.copySnapshot = rt.copySnapshot | |
| 198 | + | snap.copyDocument = rt.copyDocument | |
| 199 | 199 | if rt.copyMode != nil { | |
| 200 | 200 | copyModeValue := *rt.copyMode | |
| 201 | 201 | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | |
| @@ -36,7 +36,7 @@ type overlayRuntime struct { | |||||
| 36 | copyMu sync.Mutex | 36 | copyMu sync.Mutex | ||
| 37 | copyMode *scopy.Mode | 37 | copyMode *scopy.Mode | ||
| 38 | copyCandidate *scopy.Mode | 38 | copyCandidate *scopy.Mode | ||
| 39 | - | copySnapshot *scopy.Snapshot | 39 | + | copyDocument *scopy.Document |
| 40 | copyPane *pane | 40 | copyPane *pane | ||
| 41 | copyPending []byte | 41 | copyPending []byte | ||
| 42 | copyESC pendingByteTimer | 42 | copyESC pendingByteTimer | ||
| @@ -110,7 +110,7 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||||
| 110 | func (rt *overlayRuntime) clearCopyModeLocked() { | 110 | func (rt *overlayRuntime) clearCopyModeLocked() { | ||
| 111 | rt.copyMode = nil | 111 | rt.copyMode = nil | ||
| 112 | rt.copyCandidate = nil | 112 | rt.copyCandidate = nil | ||
| 113 | - | rt.copySnapshot = nil | 113 | + | rt.copyDocument = nil |
| 114 | rt.copyPane = nil | 114 | rt.copyPane = nil | ||
| 115 | rt.copySearch = nil | 115 | rt.copySearch = nil | ||
| 116 | rt.copySearchPending = nil | 116 | rt.copySearchPending = nil | ||
| @@ -158,7 +158,7 @@ type overlayRenderSnapshot struct { | |||||
| 158 | 158 | ||||
| 159 | copyActive bool | 159 | copyActive bool | ||
| 160 | copyMode *scopy.Mode | 160 | copyMode *scopy.Mode | ||
| 161 | - | copySnapshot *scopy.Snapshot | 161 | + | copyDocument *scopy.Document |
| 162 | copyPane *pane | 162 | copyPane *pane | ||
| 163 | copySearchModel *visualsearch.Model | 163 | copySearchModel *visualsearch.Model | ||
| 164 | copyFeedback string | 164 | copyFeedback string | ||
| @@ -195,7 +195,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 195 | rt.copyMu.Lock() | 195 | rt.copyMu.Lock() | ||
| 196 | snap.copyActive = rt.copyMode != nil | 196 | snap.copyActive = rt.copyMode != nil | ||
| 197 | snap.copyPane = rt.copyPane | 197 | snap.copyPane = rt.copyPane | ||
| 198 | - | snap.copySnapshot = rt.copySnapshot | 198 | + | snap.copyDocument = rt.copyDocument |
| 199 | if rt.copyMode != nil { | 199 | if rt.copyMode != nil { | ||
| 200 | copyModeValue := *rt.copyMode | 200 | 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) { | |||
| 114 | 114 | ac := &attachedClient{} | |
| 115 | 115 | ac.initOverlays() | |
| 116 | 116 | snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2) | |
| 117 | - | mode := scopy.NewMode(snap) | |
| 118 | - | require.True(t, mode.Search(snap, "alpha")) | |
| 117 | + | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | + | require.True(t, mode.Search("alpha")) | |
| 119 | 119 | search := visualsearch.New(snap) | |
| 120 | 120 | for _, r := range "alpha" { | |
| 121 | 121 | search.Insert(r) | |
| @@ -149,24 +149,24 @@ func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing. | |||
| 149 | 149 | ac := &attachedClient{} | |
| 150 | 150 | ac.initOverlays() | |
| 151 | 151 | p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2}) | |
| 152 | - | document := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2) | |
| 152 | + | document := scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2), domain.DefaultWordSeparators) | |
| 153 | 153 | ||
| 154 | 154 | ac.overlays.copyMu.Lock() | |
| 155 | 155 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 156 | 156 | ac.overlays.copyPane = p | |
| 157 | - | ac.overlays.copySnapshot = &document | |
| 157 | + | ac.overlays.copyDocument = document | |
| 158 | 158 | ac.overlays.copyMu.Unlock() | |
| 159 | 159 | ||
| 160 | 160 | renderSnap := ac.overlays.SnapshotForRender() | |
| 161 | 161 | require.Same(t, p, renderSnap.copyPane) | |
| 162 | - | require.Same(t, &document, renderSnap.copySnapshot) | |
| 162 | + | require.Same(t, document, renderSnap.copyDocument) | |
| 163 | 163 | renderSnap.Unlock() | |
| 164 | 164 | ||
| 165 | 165 | ac.overlays.copyMu.Lock() | |
| 166 | 166 | ac.overlays.clearCopyModeLocked() | |
| 167 | 167 | require.Nil(t, ac.overlays.copyPane) | |
| 168 | 168 | require.Nil(t, ac.overlays.copyMode) | |
| 169 | - | require.Nil(t, ac.overlays.copySnapshot) | |
| 169 | + | require.Nil(t, ac.overlays.copyDocument) | |
| 170 | 170 | ac.overlays.copyMu.Unlock() | |
| 171 | 171 | } | |
| 172 | 172 | ||
| @@ -175,16 +175,16 @@ func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) { | |||
| 175 | 175 | ac.initOverlays() | |
| 176 | 176 | history := newTestHistory(1) | |
| 177 | 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 | 180 | ac.overlays.copyMu.Lock() | |
| 181 | 181 | ac.overlays.copyMode = scopy.NewMode(document) | |
| 182 | - | ac.overlays.copySnapshot = &document | |
| 182 | + | ac.overlays.copyDocument = document | |
| 183 | 183 | ac.overlays.copyMu.Unlock() | |
| 184 | 184 | ||
| 185 | 185 | history.Append(testRow("after")) | |
| 186 | 186 | renderSnap := ac.overlays.SnapshotForRender() | |
| 187 | 187 | 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))) | |
| 190 | 190 | } | |
| @@ -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 = p | 156 | ac.overlays.copyPane = p | ||
| 157 | - | ac.overlays.copySnapshot = &document | 157 | + | 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 = &document | 182 | + | 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 { | |||
| 869 | 869 | func (f *performanceFixture) copyModeActive() bool { | |
| 870 | 870 | f.ac.overlays.copyMu.Lock() | |
| 871 | 871 | defer f.ac.overlays.copyMu.Unlock() | |
| 872 | - | return f.ac.overlays.copyMode != nil && f.ac.overlays.copySnapshot != nil && f.ac.overlays.copySnapshot.Len() >= 10_000 | |
| 872 | + | return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000 | |
| 873 | 873 | } | |
| 874 | 874 | ||
| 875 | 875 | func (f *performanceFixture) resize() { | |
| @@ -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_000 | 872 | + | return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_000 |
| 873 | } | 873 | } | ||
| 874 | 874 | ||||
| 875 | 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 { | |||
| 84 | 84 | type capturedOverlayRenderState struct { | |
| 85 | 85 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 86 | 86 | copyMode *scopy.Mode | |
| 87 | - | copySnapshot *scopy.Snapshot | |
| 88 | 87 | copyPaneID layout.PaneID | |
| 89 | 88 | copyFeedback string | |
| 90 | 89 | paletteGuidance string | |
| @@ -84,7 +84,6 @@ type capturedFloatingRenderState struct { | |||||
| 84 | type capturedOverlayRenderState struct { | 84 | type capturedOverlayRenderState struct { | ||
| 85 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | 85 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | ||
| 86 | copyMode *scopy.Mode | 86 | copyMode *scopy.Mode | ||
| 87 | - | copySnapshot *scopy.Snapshot | |||
| 88 | copyPaneID layout.PaneID | 87 | copyPaneID layout.PaneID | ||
| 89 | copyFeedback string | 88 | copyFeedback string | ||
| 90 | paletteGuidance string | 89 | paletteGuidance string | ||
M internal/usecase/daemon/render_pipeline.go +2 -2
| @@ -185,7 +185,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 185 | 185 | } | |
| 186 | 186 | o := &state.overlays | |
| 187 | 187 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | |
| 188 | - | o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot | |
| 188 | + | o.copyMode = snap.copyMode | |
| 189 | 189 | if snap.copyPane != nil { | |
| 190 | 190 | o.copyPaneID = snap.copyPane.id | |
| 191 | 191 | } | |
| @@ -236,7 +236,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||
| 236 | 236 | } | |
| 237 | 237 | } | |
| 238 | 238 | } | |
| 239 | - | frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars) | |
| 239 | + | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars) | |
| 240 | 240 | } | |
| 241 | 241 | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 242 | 242 | if o.paletteActive && !state.floating.visible { | |
| @@ -185,7 +185,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 185 | } | 185 | } | ||
| 186 | o := &state.overlays | 186 | o := &state.overlays | ||
| 187 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | 187 | o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive | ||
| 188 | - | o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot | 188 | + | o.copyMode = snap.copyMode |
| 189 | if snap.copyPane != nil { | 189 | if snap.copyPane != nil { | ||
| 190 | o.copyPaneID = snap.copyPane.id | 190 | o.copyPaneID = snap.copyPane.id | ||
| 191 | } | 191 | } | ||
| @@ -236,7 +236,7 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da | |||||
| 236 | } | 236 | } | ||
| 237 | } | 237 | } | ||
| 238 | } | 238 | } | ||
| 239 | - | frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars) | 239 | + | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.bars) |
| 240 | } | 240 | } | ||
| 241 | 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) { | |||
| 1175 | 1175 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1176 | 1176 | p.screen.Write([]byte("ab\r\ncd")) | |
| 1177 | 1177 | document := scopy.NewSnapshot(p.history, p.screen.Frame) | |
| 1178 | - | mode := scopy.NewMode(document) | |
| 1178 | + | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1179 | 1179 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1180 | 1180 | ||
| 1181 | - | frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{}) | |
| 1181 | + | frame, damage := composeCopyClientFrame(mode, target, base, barState{}) | |
| 1182 | 1182 | ||
| 1183 | 1183 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 1184 | 1184 | require.Equal(t, "##ab ", rowText(frame.Row(3))) | |
| @@ -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 { | |||
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | 108 | func (m *Model) refresh() { | |
| 109 | - | m.matches = scopy.FindMatches(m.snapshot, m.input.Value()) | |
| 109 | + | m.matches = scopy.FindMatches(scopy.NewDocument(m.snapshot, ""), m.input.Value()) | |
| 110 | 110 | m.clamp() | |
| 111 | 111 | m.ensureVisible(defaultVisibleRows) | |
| 112 | 112 | } | |
| @@ -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 | } | ||