refactor(copy): add immutable history views
- commit
- d0d286eb6dde8f9a31756369b2638762ce7f7569
- author
- brice · · 2026-07-11 05:55 UTC
- committer
- brice · · 2026-07-11 06:15 UTC
- parent
- 296e94c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +120 -64
M internal/usecase/copy/mode.go +51 -27
| @@ -15,29 +15,52 @@ import ( | |||
| 15 | 15 | // specific continuation protocol is supported. | |
| 16 | 16 | const OSC52MaxPayloadBytes = 75_000 | |
| 17 | 17 | ||
| 18 | - | // Snapshot is the scrollback-mode document: scrollback followed by the live screen. | |
| 18 | + | // Snapshot is the immutable scrollback-mode document: history followed by the | |
| 19 | + | // visible screen. History rows are immutable by Scrollback's append contract; | |
| 20 | + | // the visible frame is cloned once when the snapshot is constructed. | |
| 19 | 21 | type Snapshot struct { | |
| 20 | - | Rows [][]renderer.Cell | |
| 21 | - | Width int | |
| 22 | - | Height int | |
| 22 | + | history HistoryView | |
| 23 | + | screen renderer.Frame | |
| 24 | + | Width int | |
| 25 | + | Height int | |
| 23 | 26 | } | |
| 24 | 27 | ||
| 25 | - | // NewSnapshot copies rows from scrollback and then the live screen frame. | |
| 28 | + | // NewSnapshot freezes the current scrollback view and clones the visible screen. | |
| 26 | 29 | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |
| 27 | - | scrollRows := 0 | |
| 30 | + | var history HistoryView | |
| 28 | 31 | if sb != nil { | |
| 29 | - | scrollRows = sb.Len() | |
| 32 | + | history = sb.View() | |
| 30 | 33 | } | |
| 31 | - | rows := make([][]renderer.Cell, 0, scrollRows+screen.Height) | |
| 32 | - | if sb != nil { | |
| 33 | - | for i := range sb.Len() { | |
| 34 | - | rows = append(rows, sb.Row(i)) | |
| 35 | - | } | |
| 34 | + | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. | |
| 38 | + | // It is intended for callers that already have a complete document rather than | |
| 39 | + | // a scrollback and visible frame. | |
| 40 | + | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |
| 41 | + | owned := make([][]renderer.Cell, len(rows)) | |
| 42 | + | for i, row := range rows { | |
| 43 | + | owned[i] = append([]renderer.Cell(nil), row...) | |
| 36 | 44 | } | |
| 37 | - | for y := range screen.Height { | |
| 38 | - | rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...)) | |
| 45 | + | return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Len returns the number of document rows. | |
| 49 | + | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | |
| 50 | + | ||
| 51 | + | // Row returns document row i, or nil when i is out of range. | |
| 52 | + | func (s Snapshot) Row(i int) []renderer.Cell { | |
| 53 | + | if i < 0 { | |
| 54 | + | return nil | |
| 55 | + | } | |
| 56 | + | if i < s.history.Len() { | |
| 57 | + | return s.history.Row(i) | |
| 58 | + | } | |
| 59 | + | i -= s.history.Len() | |
| 60 | + | if i >= s.screen.Height { | |
| 61 | + | return nil | |
| 39 | 62 | } | |
| 40 | - | return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height} | |
| 63 | + | return s.screen.Row(i) | |
| 41 | 64 | } | |
| 42 | 65 | ||
| 43 | 66 | // Mode stores per-client scrollback viewport and line-selection state. | |
| @@ -60,8 +83,8 @@ type Mode struct { | |||
| 60 | 83 | ||
| 61 | 84 | func NewMode(s Snapshot) *Mode { | |
| 62 | 85 | m := &Mode{Anchor: -1} | |
| 63 | - | m.ViewportTop = max(len(s.Rows)-s.Height, 0) | |
| 64 | - | m.Cursor = max(len(s.Rows)-1, 0) | |
| 86 | + | m.ViewportTop = max(s.Len()-s.Height, 0) | |
| 87 | + | m.Cursor = max(s.Len()-1, 0) | |
| 65 | 88 | m.clamp(s) | |
| 66 | 89 | return m | |
| 67 | 90 | } | |
| @@ -69,7 +92,7 @@ func NewMode(s Snapshot) *Mode { | |||
| 69 | 92 | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | |
| 70 | 93 | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 71 | 94 | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | |
| 72 | - | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) } | |
| 95 | + | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) } | |
| 73 | 96 | ||
| 74 | 97 | func FindMatches(s Snapshot, query string) []SearchMatch { | |
| 75 | 98 | query = strings.TrimSpace(query) | |
| @@ -78,7 +101,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||
| 78 | 101 | } | |
| 79 | 102 | needle := lowerRunes(query) | |
| 80 | 103 | matches := make([]SearchMatch, 0) | |
| 81 | - | for row, cells := range s.Rows { | |
| 104 | + | for row := range s.Len() { | |
| 105 | + | cells := s.Row(row) | |
| 82 | 106 | haystack, cellIndexes := searchableCells(cells) | |
| 83 | 107 | var text string | |
| 84 | 108 | for start := 0; start+len(needle) <= len(haystack); { | |
| @@ -190,7 +214,7 @@ func (m *Mode) StartSelectionAt(s Snapshot, row int) { | |||
| 190 | 214 | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | |
| 191 | 215 | ||
| 192 | 216 | func (m *Mode) AtBottom(s Snapshot) bool { | |
| 193 | - | total := len(s.Rows) | |
| 217 | + | total := s.Len() | |
| 194 | 218 | if total == 0 { | |
| 195 | 219 | return m.Cursor == 0 && m.ViewportTop == 0 | |
| 196 | 220 | } | |
| @@ -220,7 +244,7 @@ func (m *Mode) setCursor(s Snapshot, cursor int) { | |||
| 220 | 244 | } | |
| 221 | 245 | ||
| 222 | 246 | func (m *Mode) clamp(s Snapshot) { | |
| 223 | - | total := len(s.Rows) | |
| 247 | + | total := s.Len() | |
| 224 | 248 | if total == 0 { | |
| 225 | 249 | m.Cursor, m.ViewportTop = 0, 0 | |
| 226 | 250 | return | |
| @@ -243,14 +267,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) { | |||
| 243 | 267 | ||
| 244 | 268 | func (m *Mode) SelectedText(s Snapshot) string { | |
| 245 | 269 | lo, hi, ok := m.SelectedBounds() | |
| 246 | - | if !ok || len(s.Rows) == 0 { | |
| 270 | + | if !ok || s.Len() == 0 { | |
| 247 | 271 | return "" | |
| 248 | 272 | } | |
| 249 | 273 | lo = max(lo, 0) | |
| 250 | - | hi = min(hi, len(s.Rows)-1) | |
| 274 | + | hi = min(hi, s.Len()-1) | |
| 251 | 275 | lines := make([]string, 0, hi-lo+1) | |
| 252 | 276 | for i := lo; i <= hi; i++ { | |
| 253 | - | lines = append(lines, rowString(s.Rows[i])) | |
| 277 | + | lines = append(lines, rowString(s.Row(i))) | |
| 254 | 278 | } | |
| 255 | 279 | return strings.Join(lines, "\n") | |
| 256 | 280 | } | |
| @@ -262,11 +286,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |||
| 262 | 286 | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | |
| 263 | 287 | for y := range s.Height { | |
| 264 | 288 | src := m.ViewportTop + y | |
| 265 | - | if src >= len(s.Rows) { | |
| 289 | + | if src >= s.Len() { | |
| 266 | 290 | break | |
| 267 | 291 | } | |
| 268 | 292 | row := frame.Row(y) | |
| 269 | - | copy(row, s.Rows[src]) | |
| 293 | + | copy(row, s.Row(src)) | |
| 270 | 294 | lineSelected := selected && src >= lo && src <= hi | |
| 271 | 295 | if lineSelected { | |
| 272 | 296 | for x := range row { | |
| @@ -286,7 +310,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |||
| 286 | 310 | if len(styles) > 0 { | |
| 287 | 311 | style = styles[0] | |
| 288 | 312 | } | |
| 289 | - | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style) | |
| 313 | + | drawCopyStatus(frame.Row(s.Height), m, s.Len(), style) | |
| 290 | 314 | return frame | |
| 291 | 315 | } | |
| 292 | 316 | ||
| @@ -15,29 +15,52 @@ import ( | |||||
| 15 | // specific continuation protocol is supported. | 15 | // specific continuation protocol is supported. | ||
| 16 | const OSC52MaxPayloadBytes = 75_000 | 16 | const OSC52MaxPayloadBytes = 75_000 | ||
| 17 | 17 | ||||
| 18 | - | // Snapshot is the scrollback-mode document: scrollback followed by the live screen. | 18 | + | // Snapshot is the immutable scrollback-mode document: history followed by the |
| 19 | + | // visible screen. History rows are immutable by Scrollback's append contract; | |||
| 20 | + | // the visible frame is cloned once when the snapshot is constructed. | |||
| 19 | type Snapshot struct { | 21 | type Snapshot struct { | ||
| 20 | - | Rows [][]renderer.Cell | 22 | + | history HistoryView |
| 21 | - | Width int | 23 | + | screen renderer.Frame |
| 22 | - | Height int | 24 | + | Width int |
| 25 | + | Height int | |||
| 23 | } | 26 | } | ||
| 24 | 27 | ||||
| 25 | - | // NewSnapshot copies rows from scrollback and then the live screen frame. | 28 | + | // NewSnapshot freezes the current scrollback view and clones the visible screen. |
| 26 | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | 29 | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | ||
| 27 | - | scrollRows := 0 | 30 | + | var history HistoryView |
| 28 | if sb != nil { | 31 | if sb != nil { | ||
| 29 | - | scrollRows = sb.Len() | 32 | + | history = sb.View() |
| 30 | } | 33 | } | ||
| 31 | - | rows := make([][]renderer.Cell, 0, scrollRows+screen.Height) | 34 | + | return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height} |
| 32 | - | if sb != nil { | 35 | + | } |
| 33 | - | for i := range sb.Len() { | 36 | + | |
| 34 | - | rows = append(rows, sb.Row(i)) | 37 | + | // NewSnapshotFromRows constructs a snapshot that owns explicit caller rows. |
| 35 | - | } | 38 | + | // It is intended for callers that already have a complete document rather than |
| 39 | + | // a scrollback and visible frame. | |||
| 40 | + | func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot { | |||
| 41 | + | owned := make([][]renderer.Cell, len(rows)) | |||
| 42 | + | for i, row := range rows { | |||
| 43 | + | owned[i] = append([]renderer.Cell(nil), row...) | |||
| 36 | } | 44 | } | ||
| 37 | - | for y := range screen.Height { | 45 | + | return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height} |
| 38 | - | rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...)) | 46 | + | } |
| 47 | + | ||||
| 48 | + | // Len returns the number of document rows. | |||
| 49 | + | func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height } | |||
| 50 | + | ||||
| 51 | + | // Row returns document row i, or nil when i is out of range. | |||
| 52 | + | func (s Snapshot) Row(i int) []renderer.Cell { | |||
| 53 | + | if i < 0 { | |||
| 54 | + | return nil | |||
| 55 | + | } | |||
| 56 | + | if i < s.history.Len() { | |||
| 57 | + | return s.history.Row(i) | |||
| 58 | + | } | |||
| 59 | + | i -= s.history.Len() | |||
| 60 | + | if i >= s.screen.Height { | |||
| 61 | + | return nil | |||
| 39 | } | 62 | } | ||
| 40 | - | return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height} | 63 | + | return s.screen.Row(i) |
| 41 | } | 64 | } | ||
| 42 | 65 | ||||
| 43 | // Mode stores per-client scrollback viewport and line-selection state. | 66 | // Mode stores per-client scrollback viewport and line-selection state. | ||
| @@ -60,8 +83,8 @@ type Mode struct { | |||||
| 60 | 83 | ||||
| 61 | func NewMode(s Snapshot) *Mode { | 84 | func NewMode(s Snapshot) *Mode { | ||
| 62 | m := &Mode{Anchor: -1} | 85 | m := &Mode{Anchor: -1} | ||
| 63 | - | m.ViewportTop = max(len(s.Rows)-s.Height, 0) | 86 | + | m.ViewportTop = max(s.Len()-s.Height, 0) |
| 64 | - | m.Cursor = max(len(s.Rows)-1, 0) | 87 | + | m.Cursor = max(s.Len()-1, 0) |
| 65 | m.clamp(s) | 88 | m.clamp(s) | ||
| 66 | return m | 89 | return m | ||
| 67 | } | 90 | } | ||
| @@ -69,7 +92,7 @@ func NewMode(s Snapshot) *Mode { | |||||
| 69 | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | 92 | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | ||
| 70 | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | 93 | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | ||
| 71 | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | 94 | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | ||
| 72 | - | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) } | 95 | + | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) } |
| 73 | 96 | ||||
| 74 | func FindMatches(s Snapshot, query string) []SearchMatch { | 97 | func FindMatches(s Snapshot, query string) []SearchMatch { | ||
| 75 | query = strings.TrimSpace(query) | 98 | query = strings.TrimSpace(query) | ||
| @@ -78,7 +101,8 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||||
| 78 | } | 101 | } | ||
| 79 | needle := lowerRunes(query) | 102 | needle := lowerRunes(query) | ||
| 80 | matches := make([]SearchMatch, 0) | 103 | matches := make([]SearchMatch, 0) | ||
| 81 | - | for row, cells := range s.Rows { | 104 | + | for row := range s.Len() { |
| 105 | + | cells := s.Row(row) | |||
| 82 | haystack, cellIndexes := searchableCells(cells) | 106 | haystack, cellIndexes := searchableCells(cells) | ||
| 83 | var text string | 107 | var text string | ||
| 84 | for start := 0; start+len(needle) <= len(haystack); { | 108 | for start := 0; start+len(needle) <= len(haystack); { | ||
| @@ -190,7 +214,7 @@ func (m *Mode) StartSelectionAt(s Snapshot, row int) { | |||||
| 190 | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | 214 | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | ||
| 191 | 215 | ||||
| 192 | func (m *Mode) AtBottom(s Snapshot) bool { | 216 | func (m *Mode) AtBottom(s Snapshot) bool { | ||
| 193 | - | total := len(s.Rows) | 217 | + | total := s.Len() |
| 194 | if total == 0 { | 218 | if total == 0 { | ||
| 195 | return m.Cursor == 0 && m.ViewportTop == 0 | 219 | return m.Cursor == 0 && m.ViewportTop == 0 | ||
| 196 | } | 220 | } | ||
| @@ -220,7 +244,7 @@ func (m *Mode) setCursor(s Snapshot, cursor int) { | |||||
| 220 | } | 244 | } | ||
| 221 | 245 | ||||
| 222 | func (m *Mode) clamp(s Snapshot) { | 246 | func (m *Mode) clamp(s Snapshot) { | ||
| 223 | - | total := len(s.Rows) | 247 | + | total := s.Len() |
| 224 | if total == 0 { | 248 | if total == 0 { | ||
| 225 | m.Cursor, m.ViewportTop = 0, 0 | 249 | m.Cursor, m.ViewportTop = 0, 0 | ||
| 226 | return | 250 | return | ||
| @@ -243,14 +267,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) { | |||||
| 243 | 267 | ||||
| 244 | func (m *Mode) SelectedText(s Snapshot) string { | 268 | func (m *Mode) SelectedText(s Snapshot) string { | ||
| 245 | lo, hi, ok := m.SelectedBounds() | 269 | lo, hi, ok := m.SelectedBounds() | ||
| 246 | - | if !ok || len(s.Rows) == 0 { | 270 | + | if !ok || s.Len() == 0 { |
| 247 | return "" | 271 | return "" | ||
| 248 | } | 272 | } | ||
| 249 | lo = max(lo, 0) | 273 | lo = max(lo, 0) | ||
| 250 | - | hi = min(hi, len(s.Rows)-1) | 274 | + | hi = min(hi, s.Len()-1) |
| 251 | lines := make([]string, 0, hi-lo+1) | 275 | lines := make([]string, 0, hi-lo+1) | ||
| 252 | for i := lo; i <= hi; i++ { | 276 | for i := lo; i <= hi; i++ { | ||
| 253 | - | lines = append(lines, rowString(s.Rows[i])) | 277 | + | lines = append(lines, rowString(s.Row(i))) |
| 254 | } | 278 | } | ||
| 255 | return strings.Join(lines, "\n") | 279 | return strings.Join(lines, "\n") | ||
| 256 | } | 280 | } | ||
| @@ -262,11 +286,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |||||
| 262 | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | 286 | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | ||
| 263 | for y := range s.Height { | 287 | for y := range s.Height { | ||
| 264 | src := m.ViewportTop + y | 288 | src := m.ViewportTop + y | ||
| 265 | - | if src >= len(s.Rows) { | 289 | + | if src >= s.Len() { |
| 266 | break | 290 | break | ||
| 267 | } | 291 | } | ||
| 268 | row := frame.Row(y) | 292 | row := frame.Row(y) | ||
| 269 | - | copy(row, s.Rows[src]) | 293 | + | copy(row, s.Row(src)) |
| 270 | lineSelected := selected && src >= lo && src <= hi | 294 | lineSelected := selected && src >= lo && src <= hi | ||
| 271 | if lineSelected { | 295 | if lineSelected { | ||
| 272 | for x := range row { | 296 | for x := range row { | ||
| @@ -286,7 +310,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |||||
| 286 | if len(styles) > 0 { | 310 | if len(styles) > 0 { | ||
| 287 | style = styles[0] | 311 | style = styles[0] | ||
| 288 | } | 312 | } | ||
| 289 | - | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style) | 313 | + | drawCopyStatus(frame.Row(s.Height), m, s.Len(), style) |
| 290 | return frame | 314 | return frame | ||
| 291 | } | 315 | } | ||
| 292 | 316 | ||||
M internal/usecase/copy/mode_test.go +4 -4
| @@ -15,7 +15,7 @@ func snapshot(lines []string, height int) Snapshot { | |||
| 15 | 15 | for i, line := range lines { | |
| 16 | 16 | rows[i] = row(line) | |
| 17 | 17 | } | |
| 18 | - | return Snapshot{Rows: rows, Width: 16, Height: height} | |
| 18 | + | return NewSnapshotFromRows(rows, 16, height) | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | func inverseRow(text string) []renderer.Cell { | |
| @@ -226,7 +226,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | |||
| 226 | 226 | ||
| 227 | 227 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |
| 228 | 228 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 229 | - | s.Rows[1] = inverseRow("beta ") | |
| 229 | + | s = NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2) | |
| 230 | 230 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 231 | 231 | m.Move(s, 1) | |
| 232 | 232 | ||
| @@ -274,7 +274,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) { | |||
| 274 | 274 | // flip it back to non-inverse and hide the highlight. | |
| 275 | 275 | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | |
| 276 | 276 | rows[0][0].Style.Inverse = true | |
| 277 | - | s := Snapshot{Rows: rows, Width: 16, Height: 2} | |
| 277 | + | s := NewSnapshotFromRows(rows, 16, 2) | |
| 278 | 278 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 279 | 279 | ||
| 280 | 280 | frame := m.Render(s) | |
| @@ -365,7 +365,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | |||
| 365 | 365 | {Rune: 'h'}, | |
| 366 | 366 | {Rune: 'a'}, | |
| 367 | 367 | } | |
| 368 | - | s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1} | |
| 368 | + | s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1) | |
| 369 | 369 | ||
| 370 | 370 | wideMatches := FindMatches(s, "界") | |
| 371 | 371 | require.Len(t, wideMatches, 1) | |
| @@ -15,7 +15,7 @@ func snapshot(lines []string, height int) Snapshot { | |||||
| 15 | for i, line := range lines { | 15 | for i, line := range lines { | ||
| 16 | rows[i] = row(line) | 16 | rows[i] = row(line) | ||
| 17 | } | 17 | } | ||
| 18 | - | return Snapshot{Rows: rows, Width: 16, Height: height} | 18 | + | return NewSnapshotFromRows(rows, 16, height) |
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | func inverseRow(text string) []renderer.Cell { | 21 | func inverseRow(text string) []renderer.Cell { | ||
| @@ -226,7 +226,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | |||||
| 226 | 226 | ||||
| 227 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | 227 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | ||
| 228 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | 228 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | ||
| 229 | - | s.Rows[1] = inverseRow("beta ") | 229 | + | s = NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2) |
| 230 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | 230 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | ||
| 231 | m.Move(s, 1) | 231 | m.Move(s, 1) | ||
| 232 | 232 | ||||
| @@ -274,7 +274,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) { | |||||
| 274 | // flip it back to non-inverse and hide the highlight. | 274 | // flip it back to non-inverse and hide the highlight. | ||
| 275 | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | 275 | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | ||
| 276 | rows[0][0].Style.Inverse = true | 276 | rows[0][0].Style.Inverse = true | ||
| 277 | - | s := Snapshot{Rows: rows, Width: 16, Height: 2} | 277 | + | s := NewSnapshotFromRows(rows, 16, 2) |
| 278 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | 278 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | ||
| 279 | 279 | ||||
| 280 | frame := m.Render(s) | 280 | frame := m.Render(s) | ||
| @@ -365,7 +365,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | |||||
| 365 | {Rune: 'h'}, | 365 | {Rune: 'h'}, | ||
| 366 | {Rune: 'a'}, | 366 | {Rune: 'a'}, | ||
| 367 | } | 367 | } | ||
| 368 | - | s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1} | 368 | + | s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1) |
| 369 | 369 | ||||
| 370 | wideMatches := FindMatches(s, "界") | 370 | wideMatches := FindMatches(s, "界") | ||
| 371 | require.Len(t, wideMatches, 1) | 371 | require.Len(t, wideMatches, 1) | ||
M internal/usecase/copy/scrollback.go +0 -9
| @@ -81,12 +81,3 @@ func (v HistoryView) Row(i int) []renderer.Cell { | |||
| 81 | 81 | } | |
| 82 | 82 | return v.rows[i] | |
| 83 | 83 | } | |
| 84 | - | ||
| 85 | - | // Row returns a copy of the retained row at logical index i, oldest first. | |
| 86 | - | func (s *Scrollback) Row(i int) []renderer.Cell { | |
| 87 | - | if i < 0 || i >= s.len || len(s.rows) == 0 { | |
| 88 | - | return nil | |
| 89 | - | } | |
| 90 | - | row := s.rows[(s.head+i)%len(s.rows)] | |
| 91 | - | return append([]renderer.Cell(nil), row...) | |
| 92 | - | } | |
| @@ -81,12 +81,3 @@ func (v HistoryView) Row(i int) []renderer.Cell { | |||||
| 81 | } | 81 | } | ||
| 82 | return v.rows[i] | 82 | return v.rows[i] | ||
| 83 | } | 83 | } | ||
| 84 | - | ||||
| 85 | - | // Row returns a copy of the retained row at logical index i, oldest first. | |||
| 86 | - | func (s *Scrollback) Row(i int) []renderer.Cell { | |||
| 87 | - | if i < 0 || i >= s.len || len(s.rows) == 0 { | |||
| 88 | - | return nil | |||
| 89 | - | } | |||
| 90 | - | row := s.rows[(s.head+i)%len(s.rows)] | |||
| 91 | - | return append([]renderer.Cell(nil), row...) | |||
| 92 | - | } | |||
M internal/usecase/copy/scrollback_test.go +4 -6
| @@ -82,7 +82,7 @@ func TestScrollbackRing(t *testing.T) { | |||
| 82 | 82 | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | |
| 83 | 83 | } | |
| 84 | 84 | for i, want := range tt.wantRows { | |
| 85 | - | if got := rowText(sb.Row(i)); got != want { | |
| 85 | + | if got := rowText(sb.View().Row(i)); got != want { | |
| 86 | 86 | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | |
| 87 | 87 | } | |
| 88 | 88 | } | |
| @@ -103,10 +103,8 @@ func TestScrollbackCopiesRows(t *testing.T) { | |||
| 103 | 103 | sb.Append(r) | |
| 104 | 104 | r[0].Rune = 'z' | |
| 105 | 105 | ||
| 106 | - | got := sb.Row(0) | |
| 107 | - | got[1].Rune = 'y' | |
| 108 | - | if text := rowText(sb.Row(0)); text != "ab" { | |
| 109 | - | t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text) | |
| 106 | + | if text := rowText(sb.View().Row(0)); text != "ab" { | |
| 107 | + | t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text) | |
| 110 | 108 | } | |
| 111 | 109 | }) | |
| 112 | 110 | } | |
| @@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) { | |||
| 151 | 149 | } | |
| 152 | 150 | if len(got) > 0 { | |
| 153 | 151 | got[0] = row("zz") | |
| 154 | - | if text := rowText(sb.Row(0)); text != tt.wantRows[0] { | |
| 152 | + | if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] { | |
| 155 | 153 | t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0]) | |
| 156 | 154 | } | |
| 157 | 155 | } | |
| @@ -82,7 +82,7 @@ func TestScrollbackRing(t *testing.T) { | |||||
| 82 | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | 82 | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | ||
| 83 | } | 83 | } | ||
| 84 | for i, want := range tt.wantRows { | 84 | for i, want := range tt.wantRows { | ||
| 85 | - | if got := rowText(sb.Row(i)); got != want { | 85 | + | if got := rowText(sb.View().Row(i)); got != want { |
| 86 | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | 86 | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | ||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| @@ -103,10 +103,8 @@ func TestScrollbackCopiesRows(t *testing.T) { | |||||
| 103 | sb.Append(r) | 103 | sb.Append(r) | ||
| 104 | r[0].Rune = 'z' | 104 | r[0].Rune = 'z' | ||
| 105 | 105 | ||||
| 106 | - | got := sb.Row(0) | 106 | + | if text := rowText(sb.View().Row(0)); text != "ab" { |
| 107 | - | got[1].Rune = 'y' | 107 | + | t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text) |
| 108 | - | if text := rowText(sb.Row(0)); text != "ab" { | |||
| 109 | - | t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text) | |||
| 110 | } | 108 | } | ||
| 111 | }) | 109 | }) | ||
| 112 | } | 110 | } | ||
| @@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) { | |||||
| 151 | } | 149 | } | ||
| 152 | if len(got) > 0 { | 150 | if len(got) > 0 { | ||
| 153 | got[0] = row("zz") | 151 | got[0] = row("zz") | ||
| 154 | - | if text := rowText(sb.Row(0)); text != tt.wantRows[0] { | 152 | + | if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] { |
| 155 | t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0]) | 153 | t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0]) | ||
| 156 | } | 154 | } | ||
| 157 | } | 155 | } | ||
A internal/usecase/copy/snapshot_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |
| 10 | + | sb := NewScrollback(1) | |
| 11 | + | sb.Append(row("old")) | |
| 12 | + | screen := renderer.NewFrame(3, 1) | |
| 13 | + | copy(screen.Row(0), row("one")) | |
| 14 | + | ||
| 15 | + | snapshot := NewSnapshot(sb, screen) | |
| 16 | + | if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] { | |
| 17 | + | t.Fatal("NewSnapshot() deep-copied immutable history") | |
| 18 | + | } | |
| 19 | + | sb.Append(row("new")) | |
| 20 | + | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |
| 21 | + | ||
| 22 | + | if got := snapshot.Len(); got != 2 { | |
| 23 | + | t.Fatalf("Len() = %d, want 2", got) | |
| 24 | + | } | |
| 25 | + | for i, want := range []string{"old", "one"} { | |
| 26 | + | if got := rowText(snapshot.Row(i)); got != want { | |
| 27 | + | t.Errorf("Row(%d) = %q, want %q", i, got, want) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | for _, i := range []int{-1, 2} { | |
| 31 | + | if got := snapshot.Row(i); got != nil { | |
| 32 | + | t.Errorf("Row(%d) = %v, want nil", i, got) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |
| 38 | + | rows := [][]renderer.Cell{row("one")} | |
| 39 | + | snapshot := NewSnapshotFromRows(rows, 3, 1) | |
| 40 | + | rows[0][0].Rune = 'x' | |
| 41 | + | if got := rowText(snapshot.Row(0)); got != "one" { | |
| 42 | + | t.Fatalf("Row(0) = %q, want deep-owned caller row", got) | |
| 43 | + | } | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) { | |||
| 10 | + | sb := NewScrollback(1) | |||
| 11 | + | sb.Append(row("old")) | |||
| 12 | + | screen := renderer.NewFrame(3, 1) | |||
| 13 | + | copy(screen.Row(0), row("one")) | |||
| 14 | + | ||||
| 15 | + | snapshot := NewSnapshot(sb, screen) | |||
| 16 | + | if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] { | |||
| 17 | + | t.Fatal("NewSnapshot() deep-copied immutable history") | |||
| 18 | + | } | |||
| 19 | + | sb.Append(row("new")) | |||
| 20 | + | screen.Set(0, 0, renderer.Cell{Rune: 'x'}) | |||
| 21 | + | ||||
| 22 | + | if got := snapshot.Len(); got != 2 { | |||
| 23 | + | t.Fatalf("Len() = %d, want 2", got) | |||
| 24 | + | } | |||
| 25 | + | for i, want := range []string{"old", "one"} { | |||
| 26 | + | if got := rowText(snapshot.Row(i)); got != want { | |||
| 27 | + | t.Errorf("Row(%d) = %q, want %q", i, got, want) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | for _, i := range []int{-1, 2} { | |||
| 31 | + | if got := snapshot.Row(i); got != nil { | |||
| 32 | + | t.Errorf("Row(%d) = %v, want nil", i, got) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) { | |||
| 38 | + | rows := [][]renderer.Cell{row("one")} | |||
| 39 | + | snapshot := NewSnapshotFromRows(rows, 3, 1) | |||
| 40 | + | rows[0][0].Rune = 'x' | |||
| 41 | + | if got := rowText(snapshot.Row(0)); got != "one" { | |||
| 42 | + | t.Fatalf("Row(0) = %q, want deep-owned caller row", got) | |||
| 43 | + | } | |||
| 44 | + | } | |||
M internal/usecase/daemon/copymode_test.go +2 -2
| @@ -403,7 +403,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 403 | 403 | } | |
| 404 | 404 | p.mu.Lock() | |
| 405 | 405 | defer p.mu.Unlock() | |
| 406 | - | return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12 | |
| 406 | + | return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12 | |
| 407 | 407 | }, 2*time.Second, 5*time.Millisecond) | |
| 408 | 408 | ||
| 409 | 409 | sess := firstSession(d) | |
| @@ -721,7 +721,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 721 | 721 | } | |
| 722 | 722 | fp.screen.Write([]byte("live")) | |
| 723 | 723 | fp.mu.Lock() | |
| 724 | - | total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows) | |
| 724 | + | total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len() | |
| 725 | 725 | fp.mu.Unlock() | |
| 726 | 726 | ||
| 727 | 727 | d.enterCopyMode(sess, ac) | |
| @@ -403,7 +403,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 403 | } | 403 | } | ||
| 404 | p.mu.Lock() | 404 | p.mu.Lock() | ||
| 405 | defer p.mu.Unlock() | 405 | defer p.mu.Unlock() | ||
| 406 | - | return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12 | 406 | + | return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12 |
| 407 | }, 2*time.Second, 5*time.Millisecond) | 407 | }, 2*time.Second, 5*time.Millisecond) | ||
| 408 | 408 | ||||
| 409 | sess := firstSession(d) | 409 | sess := firstSession(d) | ||
| @@ -721,7 +721,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||||
| 721 | } | 721 | } | ||
| 722 | fp.screen.Write([]byte("live")) | 722 | fp.screen.Write([]byte("live")) | ||
| 723 | fp.mu.Lock() | 723 | fp.mu.Lock() | ||
| 724 | - | total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows) | 724 | + | total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len() |
| 725 | fp.mu.Unlock() | 725 | fp.mu.Unlock() | ||
| 726 | 726 | ||||
| 727 | d.enterCopyMode(sess, ac) | 727 | d.enterCopyMode(sess, ac) | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -277,7 +277,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 277 | 277 | rt.copyMu.Lock() | |
| 278 | 278 | mode := scopy.NewMode(snap) | |
| 279 | 279 | mode.StartSelectionAt(snap, pressTop+pressRow) | |
| 280 | - | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | |
| 280 | + | mode.ExtendTo(snap, snap.Len()-snap.Height+ev.Row) | |
| 281 | 281 | rt.copyMode = mode | |
| 282 | 282 | rt.copyPane = p | |
| 283 | 283 | rt.copyPressRow = pressTop + pressRow | |
| @@ -277,7 +277,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 277 | rt.copyMu.Lock() | 277 | rt.copyMu.Lock() | ||
| 278 | mode := scopy.NewMode(snap) | 278 | mode := scopy.NewMode(snap) | ||
| 279 | mode.StartSelectionAt(snap, pressTop+pressRow) | 279 | mode.StartSelectionAt(snap, pressTop+pressRow) | ||
| 280 | - | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | 280 | + | mode.ExtendTo(snap, snap.Len()-snap.Height+ev.Row) |
| 281 | rt.copyMode = mode | 281 | rt.copyMode = mode | ||
| 282 | rt.copyPane = p | 282 | rt.copyPane = p | ||
| 283 | rt.copyPressRow = pressTop + pressRow | 283 | rt.copyPressRow = pressTop + pressRow | ||
M internal/usecase/daemon/overlay_runtime_test.go +2 -2
| @@ -113,7 +113,7 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) { | |||
| 113 | 113 | func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) { | |
| 114 | 114 | ac := &attachedClient{} | |
| 115 | 115 | ac.initOverlays() | |
| 116 | - | snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2} | |
| 116 | + | snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2) | |
| 117 | 117 | mode := scopy.NewMode(snap) | |
| 118 | 118 | require.True(t, mode.Search(snap, "alpha")) | |
| 119 | 119 | search := visualsearch.New(snap) | |
| @@ -149,7 +149,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) { | |||
| 149 | 149 | ac := &attachedClient{} | |
| 150 | 150 | ac.initOverlays() | |
| 151 | 151 | p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2}) | |
| 152 | - | snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2} | |
| 152 | + | snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2) | |
| 153 | 153 | ||
| 154 | 154 | ac.overlays.copyMu.Lock() | |
| 155 | 155 | ac.overlays.copyMode = scopy.NewMode(snap) | |
| @@ -113,7 +113,7 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) { | |||||
| 113 | func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) { | 113 | func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) { | ||
| 114 | ac := &attachedClient{} | 114 | ac := &attachedClient{} | ||
| 115 | ac.initOverlays() | 115 | ac.initOverlays() | ||
| 116 | - | snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2} | 116 | + | snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2) |
| 117 | mode := scopy.NewMode(snap) | 117 | mode := scopy.NewMode(snap) | ||
| 118 | require.True(t, mode.Search(snap, "alpha")) | 118 | require.True(t, mode.Search(snap, "alpha")) | ||
| 119 | search := visualsearch.New(snap) | 119 | search := visualsearch.New(snap) | ||
| @@ -149,7 +149,7 @@ func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) { | |||||
| 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 | - | snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2} | 152 | + | snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2) |
| 153 | 153 | ||||
| 154 | ac.overlays.copyMu.Lock() | 154 | ac.overlays.copyMu.Lock() | ||
| 155 | ac.overlays.copyMode = scopy.NewMode(snap) | 155 | ac.overlays.copyMode = scopy.NewMode(snap) | ||
M internal/usecase/daemon/render_test.go +2 -2
| @@ -373,8 +373,8 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 373 | 373 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 374 | 374 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | |
| 375 | 375 | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | |
| 376 | - | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0))) | |
| 377 | - | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1))) | |
| 376 | + | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0))) | |
| 377 | + | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1))) | |
| 378 | 378 | ||
| 379 | 379 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | |
| 380 | 380 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| @@ -373,8 +373,8 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 373 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | 373 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | ||
| 374 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | 374 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | ||
| 375 | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | 375 | require.Equal(t, 2, win.focusedPane().scrollback.Len()) | ||
| 376 | - | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0))) | 376 | + | require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0))) |
| 377 | - | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1))) | 377 | + | require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1))) |
| 378 | 378 | ||||
| 379 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | 379 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | ||
| 380 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | 380 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | ||
M internal/usecase/daemon/scrollback_test.go +1 -1
| @@ -36,7 +36,7 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||
| 36 | 36 | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | |
| 37 | 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | |
| 38 | 38 | } | |
| 39 | - | row := win.focusedPane().scrollback.Row(0) | |
| 39 | + | row := win.focusedPane().scrollback.View().Row(0) | |
| 40 | 40 | runes := make([]rune, len(row)) | |
| 41 | 41 | for i, c := range row { | |
| 42 | 42 | runes[i] = c.Rune | |
| @@ -36,7 +36,7 @@ func TestNewTabInitializesScrollback(t *testing.T) { | |||||
| 36 | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | 36 | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | ||
| 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | 37 | t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen) | ||
| 38 | } | 38 | } | ||
| 39 | - | row := win.focusedPane().scrollback.Row(0) | 39 | + | row := win.focusedPane().scrollback.View().Row(0) |
| 40 | runes := make([]rune, len(row)) | 40 | runes := make([]rune, len(row)) | ||
| 41 | for i, c := range row { | 41 | for i, c := range row { | ||
| 42 | runes[i] = c.Rune | 42 | runes[i] = c.Rune | ||
M internal/usecase/daemon/snapshot_restore_test.go +3 -3
| @@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 77 | 77 | p := tb.panes["pane-2"] | |
| 78 | 78 | tb.mu.Unlock() | |
| 79 | 79 | p.mu.Lock() | |
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.Row(0))) | |
| 80 | + | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) | |
| 81 | 81 | require.Equal(t, 1, p.scrollback.Len()) | |
| 82 | 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |
| 83 | 83 | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 84 | - | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | |
| 85 | - | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | |
| 84 | + | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) | |
| 85 | + | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) | |
| 86 | 86 | p.mu.Unlock() | |
| 87 | 87 | ||
| 88 | 88 | tb.mu.Lock() | |
| @@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 77 | p := tb.panes["pane-2"] | 77 | p := tb.panes["pane-2"] | ||
| 78 | tb.mu.Unlock() | 78 | tb.mu.Unlock() | ||
| 79 | p.mu.Lock() | 79 | p.mu.Lock() | ||
| 80 | - | require.Equal(t, "old2", rowText(p.scrollback.Row(0))) | 80 | + | require.Equal(t, "old2", rowText(p.scrollback.View().Row(0))) |
| 81 | require.Equal(t, 1, p.scrollback.Len()) | 81 | require.Equal(t, 1, p.scrollback.Len()) | ||
| 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | ||
| 83 | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | 83 | copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | ||
| 84 | - | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | 84 | + | require.Equal(t, "old2", rowText(copySnap.Row(0)[:4])) |
| 85 | - | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | 85 | + | require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4])) |
| 86 | p.mu.Unlock() | 86 | p.mu.Unlock() | ||
| 87 | 87 | ||||
| 88 | tb.mu.Lock() | 88 | tb.mu.Lock() | ||
M internal/usecase/visualsearch/model.go +5 -6
| @@ -36,7 +36,7 @@ func (m *Model) Clone() *Model { | |||
| 36 | 36 | ||
| 37 | 37 | func (m *Model) Snapshot() scopy.Snapshot { | |
| 38 | 38 | if m == nil { | |
| 39 | - | return scopy.Snapshot{} | |
| 39 | + | return scopy.NewSnapshotFromRows(nil, 0, 0) | |
| 40 | 40 | } | |
| 41 | 41 | return cloneSnapshot(m.snapshot) | |
| 42 | 42 | } | |
| @@ -147,12 +147,11 @@ func (m *Model) ensureVisible(visible int) { | |||
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | 149 | func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot { | |
| 150 | - | clone := s | |
| 151 | - | clone.Rows = make([][]renderer.Cell, len(s.Rows)) | |
| 152 | - | for i, row := range s.Rows { | |
| 153 | - | clone.Rows[i] = append([]renderer.Cell(nil), row...) | |
| 150 | + | rows := make([][]renderer.Cell, s.Len()) | |
| 151 | + | for i := range rows { | |
| 152 | + | rows[i] = s.Row(i) | |
| 154 | 153 | } | |
| 155 | - | return clone | |
| 154 | + | return scopy.NewSnapshotFromRows(rows, s.Width, s.Height) | |
| 156 | 155 | } | |
| 157 | 156 | ||
| 158 | 157 | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | |
| @@ -36,7 +36,7 @@ func (m *Model) Clone() *Model { | |||||
| 36 | 36 | ||||
| 37 | func (m *Model) Snapshot() scopy.Snapshot { | 37 | func (m *Model) Snapshot() scopy.Snapshot { | ||
| 38 | if m == nil { | 38 | if m == nil { | ||
| 39 | - | return scopy.Snapshot{} | 39 | + | return scopy.NewSnapshotFromRows(nil, 0, 0) |
| 40 | } | 40 | } | ||
| 41 | return cloneSnapshot(m.snapshot) | 41 | return cloneSnapshot(m.snapshot) | ||
| 42 | } | 42 | } | ||
| @@ -147,12 +147,11 @@ func (m *Model) ensureVisible(visible int) { | |||||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot { | 149 | func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot { | ||
| 150 | - | clone := s | 150 | + | rows := make([][]renderer.Cell, s.Len()) |
| 151 | - | clone.Rows = make([][]renderer.Cell, len(s.Rows)) | 151 | + | for i := range rows { |
| 152 | - | for i, row := range s.Rows { | 152 | + | rows[i] = s.Row(i) |
| 153 | - | clone.Rows[i] = append([]renderer.Cell(nil), row...) | |||
| 154 | } | 153 | } | ||
| 155 | - | return clone | 154 | + | return scopy.NewSnapshotFromRows(rows, s.Width, s.Height) |
| 156 | } | 155 | } | ||
| 157 | 156 | ||||
| 158 | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | 157 | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | ||
M internal/usecase/visualsearch/model_test.go +1 -1
| @@ -20,7 +20,7 @@ func testSnapshot(lines ...string) scopy.Snapshot { | |||
| 20 | 20 | rows[i][x] = renderer.Cell{Rune: r} | |
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | - | return scopy.Snapshot{Rows: rows, Width: 40, Height: 4} | |
| 23 | + | return scopy.NewSnapshotFromRows(rows, 40, 4) | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) { | |
| @@ -20,7 +20,7 @@ func testSnapshot(lines ...string) scopy.Snapshot { | |||||
| 20 | rows[i][x] = renderer.Cell{Rune: r} | 20 | rows[i][x] = renderer.Cell{Rune: r} | ||
| 21 | } | 21 | } | ||
| 22 | } | 22 | } | ||
| 23 | - | return scopy.Snapshot{Rows: rows, Width: 40, Height: 4} | 23 | + | return scopy.NewSnapshotFromRows(rows, 40, 4) |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) { | 26 | func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) { | ||