feat(vt): add stable row identities
- commit
- 6f5dceb2ee7c87a34ecaab340f8a3a1c3faf7d41
- author
- brice · · 2026-08-02 19:43 UTC
- committer
- brice · · 2026-08-03 17:54 UTC
- parent
- cc10813
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +392 -33
M pkg/vt/buffer.go +22 -8
| @@ -8,6 +8,7 @@ import "github.com/bnema/vev/pkg/renderer" | |||
| 8 | 8 | type buffer struct { | |
| 9 | 9 | frame renderer.Frame | |
| 10 | 10 | boundaries []LineBound | |
| 11 | + | rowIDs []RowID | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | // LineBound describes a physical row's logical extent. End is exclusive: it is | |
| @@ -20,15 +21,19 @@ type LineBound struct { | |||
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | func newBuffer(width, height int) *buffer { | |
| 23 | - | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)} | |
| 24 | + | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)} | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | func bufferFromFrame(frame renderer.Frame) *buffer { | |
| 27 | - | return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)} | |
| 28 | + | return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)} | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | func (b *buffer) clone() *buffer { | |
| 31 | - | out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)} | |
| 32 | + | out := &buffer{ | |
| 33 | + | frame: cloneFrame(b.frame), | |
| 34 | + | boundaries: append([]LineBound(nil), b.boundaries...), | |
| 35 | + | rowIDs: append([]RowID(nil), b.rowIDs...), | |
| 36 | + | } | |
| 32 | 37 | return out | |
| 33 | 38 | } | |
| 34 | 39 | ||
| @@ -106,8 +111,10 @@ func (b *buffer) scrollUp(top, bottom, n int) { | |||
| 106 | 111 | // them. | |
| 107 | 112 | b.hard(top - 1) | |
| 108 | 113 | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | |
| 114 | + | copy(b.rowIDs[top:bottom-n+1], b.rowIDs[top+n:bottom+1]) | |
| 109 | 115 | for y := bottom - n + 1; y <= bottom; y++ { | |
| 110 | 116 | b.boundaries[y] = LineBound{} | |
| 117 | + | b.rowIDs[y] = 0 | |
| 111 | 118 | } | |
| 112 | 119 | b.hard(bottom - n) | |
| 113 | 120 | } | |
| @@ -117,8 +124,10 @@ func (b *buffer) scrollDown(top, bottom, n int) { | |||
| 117 | 124 | // the last moved row no longer precedes the row below it. | |
| 118 | 125 | b.hard(top - 1) | |
| 119 | 126 | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | |
| 127 | + | copy(b.rowIDs[top+n:bottom+1], b.rowIDs[top:bottom-n+1]) | |
| 120 | 128 | for y := top; y < top+n; y++ { | |
| 121 | 129 | b.boundaries[y] = LineBound{} | |
| 130 | + | b.rowIDs[y] = 0 | |
| 122 | 131 | } | |
| 123 | 132 | b.hard(bottom) | |
| 124 | 133 | } | |
| @@ -150,7 +159,7 @@ func (b *buffer) hasSoft() bool { | |||
| 150 | 159 | ||
| 151 | 160 | // resizeFixed keeps hard physical lines independent. It is the common shell | |
| 152 | 161 | // path and avoids constructing logical-line scratch state when nothing wraps. | |
| 153 | - | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { | |
| 162 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) { | |
| 154 | 163 | anchor := 0 | |
| 155 | 164 | if active != nil { | |
| 156 | 165 | anchor = active.row | |
| @@ -158,10 +167,12 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 158 | 167 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |
| 159 | 168 | evicted := make([][]renderer.Cell, 0, shift) | |
| 160 | 169 | evictedBounds := make([]LineBound, 0, shift) | |
| 170 | + | evictedIDs := make([]RowID, 0, shift) | |
| 161 | 171 | for y := range shift { | |
| 162 | 172 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |
| 163 | 173 | // The evicted row keeps its source width, so its extent needs no clamping. | |
| 164 | 174 | evictedBounds = append(evictedBounds, b.boundaries[y]) | |
| 175 | + | evictedIDs = append(evictedIDs, b.rowIDs[y]) | |
| 165 | 176 | } | |
| 166 | 177 | // Fixed-line resizes can retain the boundary backing store. In particular, | |
| 167 | 178 | // keep its capacity through a short viewport so the common shrink/grow | |
| @@ -172,7 +183,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 172 | 183 | } else { | |
| 173 | 184 | boundaries = boundaries[:height] | |
| 174 | 185 | } | |
| 175 | - | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries} | |
| 186 | + | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries, rowIDs: make([]RowID, height)} | |
| 176 | 187 | copied := 0 | |
| 177 | 188 | for y := range height { | |
| 178 | 189 | sy := y + shift | |
| @@ -181,6 +192,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 181 | 192 | } | |
| 182 | 193 | copy(next.frame.Row(y), b.frame.Row(sy)) | |
| 183 | 194 | next.boundaries[y] = b.boundaries[sy] | |
| 195 | + | next.rowIDs[y] = b.rowIDs[sy] | |
| 184 | 196 | next.boundaries[y].End = min(next.boundaries[y].End, width) | |
| 185 | 197 | repairFrameRow(next.frame, y) | |
| 186 | 198 | copied++ | |
| @@ -193,7 +205,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 193 | 205 | } | |
| 194 | 206 | } | |
| 195 | 207 | *b = next | |
| 196 | - | return evicted, evictedBounds | |
| 208 | + | return evicted, evictedBounds, evictedIDs | |
| 197 | 209 | } | |
| 198 | 210 | ||
| 199 | 211 | type reflowPoint struct { | |
| @@ -234,7 +246,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||
| 234 | 246 | // the first maps both cursors and counts rows; the second writes only the | |
| 235 | 247 | // retained viewport (and the rows genuinely evicted to history). It never | |
| 236 | 248 | // materializes logical lines, per-cell position maps, or temporary output rows. | |
| 237 | - | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { | |
| 249 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) { | |
| 238 | 250 | if !b.hasSoft() { | |
| 239 | 251 | return b.resizeFixed(width, height, active, saved) | |
| 240 | 252 | } | |
| @@ -259,12 +271,14 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||
| 259 | 271 | var evicted [][]renderer.Cell | |
| 260 | 272 | var evictedCells []renderer.Cell | |
| 261 | 273 | var evictedBounds []LineBound | |
| 274 | + | var evictedIDs []RowID | |
| 262 | 275 | if shift > 0 { | |
| 263 | 276 | // History needs owned rows. One flat backing store replaces a temporary | |
| 264 | 277 | // allocation per evicted output row. | |
| 265 | 278 | evicted = make([][]renderer.Cell, shift) | |
| 266 | 279 | evictedCells = make([]renderer.Cell, shift*width) | |
| 267 | 280 | evictedBounds = make([]LineBound, shift) | |
| 281 | + | evictedIDs = make([]RowID, shift) | |
| 268 | 282 | for y := range evicted { | |
| 269 | 283 | evicted[y] = evictedCells[y*width : (y+1)*width] | |
| 270 | 284 | } | |
| @@ -277,7 +291,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||
| 277 | 291 | } | |
| 278 | 292 | } | |
| 279 | 293 | *b = *next | |
| 280 | - | return evicted, evictedBounds | |
| 294 | + | return evicted, evictedBounds, evictedIDs | |
| 281 | 295 | } | |
| 282 | 296 | ||
| 283 | 297 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| @@ -8,6 +8,7 @@ import "github.com/bnema/vev/pkg/renderer" | |||||
| 8 | type buffer struct { | 8 | type buffer struct { | ||
| 9 | frame renderer.Frame | 9 | frame renderer.Frame | ||
| 10 | boundaries []LineBound | 10 | boundaries []LineBound | ||
| 11 | + | rowIDs []RowID | |||
| 11 | } | 12 | } | ||
| 12 | 13 | ||||
| 13 | // LineBound describes a physical row's logical extent. End is exclusive: it is | 14 | // LineBound describes a physical row's logical extent. End is exclusive: it is | ||
| @@ -20,15 +21,19 @@ type LineBound struct { | |||||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | func newBuffer(width, height int) *buffer { | 23 | func newBuffer(width, height int) *buffer { | ||
| 23 | - | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)} | 24 | + | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)} |
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | func bufferFromFrame(frame renderer.Frame) *buffer { | 27 | func bufferFromFrame(frame renderer.Frame) *buffer { | ||
| 27 | - | return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)} | 28 | + | return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)} |
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | func (b *buffer) clone() *buffer { | 31 | func (b *buffer) clone() *buffer { | ||
| 31 | - | out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)} | 32 | + | out := &buffer{ |
| 33 | + | frame: cloneFrame(b.frame), | |||
| 34 | + | boundaries: append([]LineBound(nil), b.boundaries...), | |||
| 35 | + | rowIDs: append([]RowID(nil), b.rowIDs...), | |||
| 36 | + | } | |||
| 32 | return out | 37 | return out | ||
| 33 | } | 38 | } | ||
| 34 | 39 | ||||
| @@ -106,8 +111,10 @@ func (b *buffer) scrollUp(top, bottom, n int) { | |||||
| 106 | // them. | 111 | // them. | ||
| 107 | b.hard(top - 1) | 112 | b.hard(top - 1) | ||
| 108 | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | 113 | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | ||
| 114 | + | copy(b.rowIDs[top:bottom-n+1], b.rowIDs[top+n:bottom+1]) | |||
| 109 | for y := bottom - n + 1; y <= bottom; y++ { | 115 | for y := bottom - n + 1; y <= bottom; y++ { | ||
| 110 | b.boundaries[y] = LineBound{} | 116 | b.boundaries[y] = LineBound{} | ||
| 117 | + | b.rowIDs[y] = 0 | |||
| 111 | } | 118 | } | ||
| 112 | b.hard(bottom - n) | 119 | b.hard(bottom - n) | ||
| 113 | } | 120 | } | ||
| @@ -117,8 +124,10 @@ func (b *buffer) scrollDown(top, bottom, n int) { | |||||
| 117 | // the last moved row no longer precedes the row below it. | 124 | // the last moved row no longer precedes the row below it. | ||
| 118 | b.hard(top - 1) | 125 | b.hard(top - 1) | ||
| 119 | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | 126 | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | ||
| 127 | + | copy(b.rowIDs[top+n:bottom+1], b.rowIDs[top:bottom-n+1]) | |||
| 120 | for y := top; y < top+n; y++ { | 128 | for y := top; y < top+n; y++ { | ||
| 121 | b.boundaries[y] = LineBound{} | 129 | b.boundaries[y] = LineBound{} | ||
| 130 | + | b.rowIDs[y] = 0 | |||
| 122 | } | 131 | } | ||
| 123 | b.hard(bottom) | 132 | b.hard(bottom) | ||
| 124 | } | 133 | } | ||
| @@ -150,7 +159,7 @@ func (b *buffer) hasSoft() bool { | |||||
| 150 | 159 | ||||
| 151 | // resizeFixed keeps hard physical lines independent. It is the common shell | 160 | // resizeFixed keeps hard physical lines independent. It is the common shell | ||
| 152 | // path and avoids constructing logical-line scratch state when nothing wraps. | 161 | // path and avoids constructing logical-line scratch state when nothing wraps. | ||
| 153 | - | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { | 162 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) { |
| 154 | anchor := 0 | 163 | anchor := 0 | ||
| 155 | if active != nil { | 164 | if active != nil { | ||
| 156 | anchor = active.row | 165 | anchor = active.row | ||
| @@ -158,10 +167,12 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||||
| 158 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | 167 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | ||
| 159 | evicted := make([][]renderer.Cell, 0, shift) | 168 | evicted := make([][]renderer.Cell, 0, shift) | ||
| 160 | evictedBounds := make([]LineBound, 0, shift) | 169 | evictedBounds := make([]LineBound, 0, shift) | ||
| 170 | + | evictedIDs := make([]RowID, 0, shift) | |||
| 161 | for y := range shift { | 171 | for y := range shift { | ||
| 162 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | 172 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | ||
| 163 | // The evicted row keeps its source width, so its extent needs no clamping. | 173 | // The evicted row keeps its source width, so its extent needs no clamping. | ||
| 164 | evictedBounds = append(evictedBounds, b.boundaries[y]) | 174 | evictedBounds = append(evictedBounds, b.boundaries[y]) | ||
| 175 | + | evictedIDs = append(evictedIDs, b.rowIDs[y]) | |||
| 165 | } | 176 | } | ||
| 166 | // Fixed-line resizes can retain the boundary backing store. In particular, | 177 | // Fixed-line resizes can retain the boundary backing store. In particular, | ||
| 167 | // keep its capacity through a short viewport so the common shrink/grow | 178 | // keep its capacity through a short viewport so the common shrink/grow | ||
| @@ -172,7 +183,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||||
| 172 | } else { | 183 | } else { | ||
| 173 | boundaries = boundaries[:height] | 184 | boundaries = boundaries[:height] | ||
| 174 | } | 185 | } | ||
| 175 | - | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries} | 186 | + | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries, rowIDs: make([]RowID, height)} |
| 176 | copied := 0 | 187 | copied := 0 | ||
| 177 | for y := range height { | 188 | for y := range height { | ||
| 178 | sy := y + shift | 189 | sy := y + shift | ||
| @@ -181,6 +192,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||||
| 181 | } | 192 | } | ||
| 182 | copy(next.frame.Row(y), b.frame.Row(sy)) | 193 | copy(next.frame.Row(y), b.frame.Row(sy)) | ||
| 183 | next.boundaries[y] = b.boundaries[sy] | 194 | next.boundaries[y] = b.boundaries[sy] | ||
| 195 | + | next.rowIDs[y] = b.rowIDs[sy] | |||
| 184 | next.boundaries[y].End = min(next.boundaries[y].End, width) | 196 | next.boundaries[y].End = min(next.boundaries[y].End, width) | ||
| 185 | repairFrameRow(next.frame, y) | 197 | repairFrameRow(next.frame, y) | ||
| 186 | copied++ | 198 | copied++ | ||
| @@ -193,7 +205,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||||
| 193 | } | 205 | } | ||
| 194 | } | 206 | } | ||
| 195 | *b = next | 207 | *b = next | ||
| 196 | - | return evicted, evictedBounds | 208 | + | return evicted, evictedBounds, evictedIDs |
| 197 | } | 209 | } | ||
| 198 | 210 | ||||
| 199 | type reflowPoint struct { | 211 | type reflowPoint struct { | ||
| @@ -234,7 +246,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||||
| 234 | // the first maps both cursors and counts rows; the second writes only the | 246 | // the first maps both cursors and counts rows; the second writes only the | ||
| 235 | // retained viewport (and the rows genuinely evicted to history). It never | 247 | // retained viewport (and the rows genuinely evicted to history). It never | ||
| 236 | // materializes logical lines, per-cell position maps, or temporary output rows. | 248 | // materializes logical lines, per-cell position maps, or temporary output rows. | ||
| 237 | - | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) { | 249 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) { |
| 238 | if !b.hasSoft() { | 250 | if !b.hasSoft() { | ||
| 239 | return b.resizeFixed(width, height, active, saved) | 251 | return b.resizeFixed(width, height, active, saved) | ||
| 240 | } | 252 | } | ||
| @@ -259,12 +271,14 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||||
| 259 | var evicted [][]renderer.Cell | 271 | var evicted [][]renderer.Cell | ||
| 260 | var evictedCells []renderer.Cell | 272 | var evictedCells []renderer.Cell | ||
| 261 | var evictedBounds []LineBound | 273 | var evictedBounds []LineBound | ||
| 274 | + | var evictedIDs []RowID | |||
| 262 | if shift > 0 { | 275 | if shift > 0 { | ||
| 263 | // History needs owned rows. One flat backing store replaces a temporary | 276 | // History needs owned rows. One flat backing store replaces a temporary | ||
| 264 | // allocation per evicted output row. | 277 | // allocation per evicted output row. | ||
| 265 | evicted = make([][]renderer.Cell, shift) | 278 | evicted = make([][]renderer.Cell, shift) | ||
| 266 | evictedCells = make([]renderer.Cell, shift*width) | 279 | evictedCells = make([]renderer.Cell, shift*width) | ||
| 267 | evictedBounds = make([]LineBound, shift) | 280 | evictedBounds = make([]LineBound, shift) | ||
| 281 | + | evictedIDs = make([]RowID, shift) | |||
| 268 | for y := range evicted { | 282 | for y := range evicted { | ||
| 269 | evicted[y] = evictedCells[y*width : (y+1)*width] | 283 | evicted[y] = evictedCells[y*width : (y+1)*width] | ||
| 270 | } | 284 | } | ||
| @@ -277,7 +291,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||||
| 277 | } | 291 | } | ||
| 278 | } | 292 | } | ||
| 279 | *b = *next | 293 | *b = *next | ||
| 280 | - | return evicted, evictedBounds | 294 | + | return evicted, evictedBounds, evictedIDs |
| 281 | } | 295 | } | ||
| 282 | 296 | ||||
| 283 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | 297 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | ||
M pkg/vt/history.go +142 -10
| @@ -28,8 +28,10 @@ type History struct { | |||
| 28 | 28 | chunks []*HistoryChunk | |
| 29 | 29 | tail [][]renderer.Cell | |
| 30 | 30 | tailBounds []LineBound | |
| 31 | + | tailIDs []RowID | |
| 31 | 32 | rows int | |
| 32 | 33 | cells int | |
| 34 | + | nextRowID RowID | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| @@ -38,6 +40,7 @@ type History struct { | |||
| 38 | 40 | type HistoryChunk struct { | |
| 39 | 41 | rows [][]renderer.Cell | |
| 40 | 42 | bounds []LineBound | |
| 43 | + | rowIDs []RowID | |
| 41 | 44 | } | |
| 42 | 45 | ||
| 43 | 46 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| @@ -55,6 +58,7 @@ type HistorySnapshotView struct { | |||
| 55 | 58 | chunks []*HistoryChunk | |
| 56 | 59 | tail [][]renderer.Cell | |
| 57 | 60 | tailBounds []LineBound | |
| 61 | + | tailIDs []RowID | |
| 58 | 62 | rows int | |
| 59 | 63 | cells int | |
| 60 | 64 | } | |
| @@ -73,7 +77,7 @@ func NewHistory(config HistoryConfig) *History { | |||
| 73 | 77 | if maxCells <= 0 { | |
| 74 | 78 | maxCells = defaultHistoryMaxCells(config.MaxRows) | |
| 75 | 79 | } | |
| 76 | - | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows} | |
| 80 | + | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1} | |
| 77 | 81 | } | |
| 78 | 82 | ||
| 79 | 83 | func defaultHistoryMaxCells(maxRows int) int { | |
| @@ -83,21 +87,32 @@ func defaultHistoryMaxCells(maxRows int) int { | |||
| 83 | 87 | return maxRows * 160 | |
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | - | // Append records a copy of row along with its logical extent. Once a chunk is | |
| 87 | - | // full it is sealed forever. Rows wider than the total cell capacity are | |
| 88 | - | // rejected without mutation. | |
| 89 | - | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |
| 90 | + | // Append records a copy of row along with its logical extent and optional row | |
| 91 | + | // identity. Calls without an identity receive a history-owned nonzero ID. | |
| 92 | + | // Once a chunk is full it is sealed forever. Rows wider than the total cell | |
| 93 | + | // capacity are rejected without mutation. | |
| 94 | + | func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error { | |
| 90 | 95 | if h == nil || h.maxRows == 0 { | |
| 91 | 96 | return nil | |
| 92 | 97 | } | |
| 93 | 98 | if len(row) > h.maxCells { | |
| 94 | 99 | return ErrHistoryRowTooWide | |
| 95 | 100 | } | |
| 101 | + | id := RowID(0) | |
| 102 | + | if len(ids) > 0 { | |
| 103 | + | id = ids[0] | |
| 104 | + | } | |
| 105 | + | if id == 0 || h.hasRowID(id) { | |
| 106 | + | id = h.allocateRowID() | |
| 107 | + | } else if id >= h.nextRowID { | |
| 108 | + | h.nextRowID = id + 1 | |
| 109 | + | } | |
| 96 | 110 | // Make room before adding so Cells cannot overflow even when the default | |
| 97 | 111 | // capacity is MaxInt. | |
| 98 | 112 | h.evictFor(len(row)) | |
| 99 | 113 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 100 | 114 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |
| 115 | + | h.tailIDs = append(h.tailIDs, id) | |
| 101 | 116 | h.rows++ | |
| 102 | 117 | h.cells += len(row) | |
| 103 | 118 | if len(h.tail) == h.chunkRows { | |
| @@ -106,6 +121,54 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |||
| 106 | 121 | return nil | |
| 107 | 122 | } | |
| 108 | 123 | ||
| 124 | + | func (h *History) allocateRowID() RowID { | |
| 125 | + | if h.nextRowID == 0 { | |
| 126 | + | h.nextRowID = 1 | |
| 127 | + | } | |
| 128 | + | for h.hasRowID(h.nextRowID) { | |
| 129 | + | if h.nextRowID == ^RowID(0) { | |
| 130 | + | panic("vt: history row ID space exhausted") | |
| 131 | + | } | |
| 132 | + | h.nextRowID++ | |
| 133 | + | } | |
| 134 | + | id := h.nextRowID | |
| 135 | + | if h.nextRowID == ^RowID(0) { | |
| 136 | + | panic("vt: history row ID space exhausted") | |
| 137 | + | } | |
| 138 | + | h.nextRowID++ | |
| 139 | + | return id | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (h *History) assignChunkIDs(chunk *HistoryChunk) { | |
| 143 | + | if chunk == nil { | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | chunk.rowIDs = growRowIDs(chunk.rowIDs, len(chunk.rows)) | |
| 147 | + | for i := range chunk.rows { | |
| 148 | + | if chunk.rowIDs[i] == 0 || h.hasRowID(chunk.rowIDs[i]) { | |
| 149 | + | chunk.rowIDs[i] = h.allocateRowID() | |
| 150 | + | } else if chunk.rowIDs[i] >= h.nextRowID { | |
| 151 | + | h.nextRowID = chunk.rowIDs[i] + 1 | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (h *History) hasRowID(id RowID) bool { | |
| 157 | + | for _, chunk := range h.chunks { | |
| 158 | + | for _, candidate := range chunk.rowIDs { | |
| 159 | + | if candidate == id { | |
| 160 | + | return true | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | for _, candidate := range h.tailIDs { | |
| 165 | + | if candidate == id { | |
| 166 | + | return true | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | ||
| 109 | 172 | // clampBound keeps End inside the row it describes so a persisted bound can be | |
| 110 | 173 | // validated without consulting its row again. | |
| 111 | 174 | func clampBound(b LineBound, cells int) LineBound { | |
| @@ -124,26 +187,44 @@ func growBounds(bounds []LineBound, rows int) []LineBound { | |||
| 124 | 187 | return out | |
| 125 | 188 | } | |
| 126 | 189 | ||
| 190 | + | func growRowIDs(ids []RowID, rows int) []RowID { | |
| 191 | + | if len(ids) == rows { | |
| 192 | + | return ids | |
| 193 | + | } | |
| 194 | + | out := make([]RowID, rows) | |
| 195 | + | copy(out, ids) | |
| 196 | + | return out | |
| 197 | + | } | |
| 198 | + | ||
| 127 | 199 | func (h *History) sealTail() { | |
| 128 | 200 | if len(h.tail) == 0 { | |
| 129 | 201 | return | |
| 130 | 202 | } | |
| 131 | - | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))}) | |
| 203 | + | h.chunks = append(h.chunks, &HistoryChunk{ | |
| 204 | + | rows: h.tail, | |
| 205 | + | bounds: growBounds(h.tailBounds, len(h.tail)), | |
| 206 | + | rowIDs: h.tailIDs, | |
| 207 | + | }) | |
| 132 | 208 | h.tail = nil | |
| 133 | 209 | h.tailBounds = nil | |
| 210 | + | h.tailIDs = nil | |
| 134 | 211 | } | |
| 135 | 212 | ||
| 136 | 213 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |
| 137 | 214 | // chunkRows. Restored snapshots may have been written with a different chunk | |
| 138 | 215 | // size than the current history configuration. | |
| 139 | 216 | func (h *History) normalizeTail() { | |
| 217 | + | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)) | |
| 140 | 218 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | |
| 219 | + | bounds := growBounds(h.tailBounds, len(h.tail)) | |
| 141 | 220 | h.chunks = append(h.chunks, &HistoryChunk{ | |
| 142 | 221 | rows: h.tail[:h.chunkRows], | |
| 143 | - | bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows], | |
| 222 | + | bounds: bounds[:h.chunkRows], | |
| 223 | + | rowIDs: h.tailIDs[:h.chunkRows], | |
| 144 | 224 | }) | |
| 145 | 225 | h.tail = h.tail[h.chunkRows:] | |
| 146 | - | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:] | |
| 226 | + | h.tailBounds = bounds[h.chunkRows:] | |
| 227 | + | h.tailIDs = h.tailIDs[h.chunkRows:] | |
| 147 | 228 | } | |
| 148 | 229 | } | |
| 149 | 230 | ||
| @@ -172,7 +253,13 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 172 | 253 | } else { | |
| 173 | 254 | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 174 | 255 | // retained view may still refer to the original immutable chunk. | |
| 175 | - | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]} | |
| 256 | + | bounds := growBounds(chunk.bounds, len(chunk.rows)) | |
| 257 | + | rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows)) | |
| 258 | + | h.chunks[0] = &HistoryChunk{ | |
| 259 | + | rows: chunk.rows[1:], | |
| 260 | + | bounds: bounds[1:], | |
| 261 | + | rowIDs: rowIDs[1:], | |
| 262 | + | } | |
| 176 | 263 | } | |
| 177 | 264 | continue | |
| 178 | 265 | } | |
| @@ -185,6 +272,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 185 | 272 | h.tail[0] = nil | |
| 186 | 273 | h.tail = h.tail[1:] | |
| 187 | 274 | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |
| 275 | + | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | |
| 188 | 276 | } | |
| 189 | 277 | } | |
| 190 | 278 | ||
| @@ -208,6 +296,7 @@ func (h *History) View() HistoryView { | |||
| 208 | 296 | chunks = append(chunks, &HistoryChunk{ | |
| 209 | 297 | rows: cloneHistoryRows(h.tail), | |
| 210 | 298 | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 299 | + | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | |
| 211 | 300 | }) | |
| 212 | 301 | } | |
| 213 | 302 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | |
| @@ -223,6 +312,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 223 | 312 | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 224 | 313 | tail: cloneHistoryRows(h.tail), | |
| 225 | 314 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 315 | + | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | |
| 226 | 316 | rows: h.rows, | |
| 227 | 317 | cells: h.cells, | |
| 228 | 318 | } | |
| @@ -316,6 +406,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 316 | 406 | return nil | |
| 317 | 407 | } | |
| 318 | 408 | ||
| 409 | + | // RowID returns the identity of the row at i, or zero when i is out of range. | |
| 410 | + | func (v HistoryView) RowID(i int) RowID { | |
| 411 | + | if i < 0 { | |
| 412 | + | return 0 | |
| 413 | + | } | |
| 414 | + | for _, chunk := range v.chunks { | |
| 415 | + | if i < len(chunk.rows) { | |
| 416 | + | if i < len(chunk.rowIDs) { | |
| 417 | + | return chunk.rowIDs[i] | |
| 418 | + | } | |
| 419 | + | return 0 | |
| 420 | + | } | |
| 421 | + | i -= len(chunk.rows) | |
| 422 | + | } | |
| 423 | + | return 0 | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // FindRowID returns the oldest-first index of id, or -1 when id is absent. | |
| 427 | + | func (v HistoryView) FindRowID(id RowID) int { | |
| 428 | + | if id == 0 { | |
| 429 | + | return -1 | |
| 430 | + | } | |
| 431 | + | index := 0 | |
| 432 | + | for _, chunk := range v.chunks { | |
| 433 | + | for row := range chunk.rows { | |
| 434 | + | if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id { | |
| 435 | + | return index | |
| 436 | + | } | |
| 437 | + | index++ | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | return -1 | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // RowID returns the identity of the row within an immutable chunk. | |
| 444 | + | func (c *HistoryChunk) RowID(i int) RowID { | |
| 445 | + | if c == nil || i < 0 || i >= len(c.rowIDs) { | |
| 446 | + | return 0 | |
| 447 | + | } | |
| 448 | + | return c.rowIDs[i] | |
| 449 | + | } | |
| 450 | + | ||
| 319 | 451 | // Bound returns the logical extent of the row at i, or the zero value when i is | |
| 320 | 452 | // out of range. A zero value describes a hard row whose End is not meaningful. | |
| 321 | 453 | func (v HistoryView) Bound(i int) LineBound { | |
| @@ -352,7 +484,7 @@ func (v HistorySnapshotView) Tail() HistoryView { | |||
| 352 | 484 | return HistoryView{} | |
| 353 | 485 | } | |
| 354 | 486 | return HistoryView{ | |
| 355 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}}, | |
| 487 | + | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | |
| 356 | 488 | rows: len(v.tail), | |
| 357 | 489 | cells: historyRowsCells(v.tail), | |
| 358 | 490 | } | |
| @@ -28,8 +28,10 @@ type History struct { | |||||
| 28 | chunks []*HistoryChunk | 28 | chunks []*HistoryChunk | ||
| 29 | tail [][]renderer.Cell | 29 | tail [][]renderer.Cell | ||
| 30 | tailBounds []LineBound | 30 | tailBounds []LineBound | ||
| 31 | + | tailIDs []RowID | |||
| 31 | rows int | 32 | rows int | ||
| 32 | cells int | 33 | cells int | ||
| 34 | + | nextRowID RowID | |||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 35 | // HistoryChunk is an immutable group of history rows. Its identity is stable | 37 | // HistoryChunk is an immutable group of history rows. Its identity is stable | ||
| @@ -38,6 +40,7 @@ type History struct { | |||||
| 38 | type HistoryChunk struct { | 40 | type HistoryChunk struct { | ||
| 39 | rows [][]renderer.Cell | 41 | rows [][]renderer.Cell | ||
| 40 | bounds []LineBound | 42 | bounds []LineBound | ||
| 43 | + | rowIDs []RowID | |||
| 41 | } | 44 | } | ||
| 42 | 45 | ||||
| 43 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | 46 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | ||
| @@ -55,6 +58,7 @@ type HistorySnapshotView struct { | |||||
| 55 | chunks []*HistoryChunk | 58 | chunks []*HistoryChunk | ||
| 56 | tail [][]renderer.Cell | 59 | tail [][]renderer.Cell | ||
| 57 | tailBounds []LineBound | 60 | tailBounds []LineBound | ||
| 61 | + | tailIDs []RowID | |||
| 58 | rows int | 62 | rows int | ||
| 59 | cells int | 63 | cells int | ||
| 60 | } | 64 | } | ||
| @@ -73,7 +77,7 @@ func NewHistory(config HistoryConfig) *History { | |||||
| 73 | if maxCells <= 0 { | 77 | if maxCells <= 0 { | ||
| 74 | maxCells = defaultHistoryMaxCells(config.MaxRows) | 78 | maxCells = defaultHistoryMaxCells(config.MaxRows) | ||
| 75 | } | 79 | } | ||
| 76 | - | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows} | 80 | + | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1} |
| 77 | } | 81 | } | ||
| 78 | 82 | ||||
| 79 | func defaultHistoryMaxCells(maxRows int) int { | 83 | func defaultHistoryMaxCells(maxRows int) int { | ||
| @@ -83,21 +87,32 @@ func defaultHistoryMaxCells(maxRows int) int { | |||||
| 83 | return maxRows * 160 | 87 | return maxRows * 160 | ||
| 84 | } | 88 | } | ||
| 85 | 89 | ||||
| 86 | - | // Append records a copy of row along with its logical extent. Once a chunk is | 90 | + | // Append records a copy of row along with its logical extent and optional row |
| 87 | - | // full it is sealed forever. Rows wider than the total cell capacity are | 91 | + | // identity. Calls without an identity receive a history-owned nonzero ID. |
| 88 | - | // rejected without mutation. | 92 | + | // Once a chunk is full it is sealed forever. Rows wider than the total cell |
| 89 | - | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | 93 | + | // capacity are rejected without mutation. |
| 94 | + | func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error { | |||
| 90 | if h == nil || h.maxRows == 0 { | 95 | if h == nil || h.maxRows == 0 { | ||
| 91 | return nil | 96 | return nil | ||
| 92 | } | 97 | } | ||
| 93 | if len(row) > h.maxCells { | 98 | if len(row) > h.maxCells { | ||
| 94 | return ErrHistoryRowTooWide | 99 | return ErrHistoryRowTooWide | ||
| 95 | } | 100 | } | ||
| 101 | + | id := RowID(0) | |||
| 102 | + | if len(ids) > 0 { | |||
| 103 | + | id = ids[0] | |||
| 104 | + | } | |||
| 105 | + | if id == 0 || h.hasRowID(id) { | |||
| 106 | + | id = h.allocateRowID() | |||
| 107 | + | } else if id >= h.nextRowID { | |||
| 108 | + | h.nextRowID = id + 1 | |||
| 109 | + | } | |||
| 96 | // Make room before adding so Cells cannot overflow even when the default | 110 | // Make room before adding so Cells cannot overflow even when the default | ||
| 97 | // capacity is MaxInt. | 111 | // capacity is MaxInt. | ||
| 98 | h.evictFor(len(row)) | 112 | h.evictFor(len(row)) | ||
| 99 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | 113 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | ||
| 100 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | 114 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | ||
| 115 | + | h.tailIDs = append(h.tailIDs, id) | |||
| 101 | h.rows++ | 116 | h.rows++ | ||
| 102 | h.cells += len(row) | 117 | h.cells += len(row) | ||
| 103 | if len(h.tail) == h.chunkRows { | 118 | if len(h.tail) == h.chunkRows { | ||
| @@ -106,6 +121,54 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |||||
| 106 | return nil | 121 | return nil | ||
| 107 | } | 122 | } | ||
| 108 | 123 | ||||
| 124 | + | func (h *History) allocateRowID() RowID { | |||
| 125 | + | if h.nextRowID == 0 { | |||
| 126 | + | h.nextRowID = 1 | |||
| 127 | + | } | |||
| 128 | + | for h.hasRowID(h.nextRowID) { | |||
| 129 | + | if h.nextRowID == ^RowID(0) { | |||
| 130 | + | panic("vt: history row ID space exhausted") | |||
| 131 | + | } | |||
| 132 | + | h.nextRowID++ | |||
| 133 | + | } | |||
| 134 | + | id := h.nextRowID | |||
| 135 | + | if h.nextRowID == ^RowID(0) { | |||
| 136 | + | panic("vt: history row ID space exhausted") | |||
| 137 | + | } | |||
| 138 | + | h.nextRowID++ | |||
| 139 | + | return id | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (h *History) assignChunkIDs(chunk *HistoryChunk) { | |||
| 143 | + | if chunk == nil { | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | chunk.rowIDs = growRowIDs(chunk.rowIDs, len(chunk.rows)) | |||
| 147 | + | for i := range chunk.rows { | |||
| 148 | + | if chunk.rowIDs[i] == 0 || h.hasRowID(chunk.rowIDs[i]) { | |||
| 149 | + | chunk.rowIDs[i] = h.allocateRowID() | |||
| 150 | + | } else if chunk.rowIDs[i] >= h.nextRowID { | |||
| 151 | + | h.nextRowID = chunk.rowIDs[i] + 1 | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (h *History) hasRowID(id RowID) bool { | |||
| 157 | + | for _, chunk := range h.chunks { | |||
| 158 | + | for _, candidate := range chunk.rowIDs { | |||
| 159 | + | if candidate == id { | |||
| 160 | + | return true | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | for _, candidate := range h.tailIDs { | |||
| 165 | + | if candidate == id { | |||
| 166 | + | return true | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | ||||
| 109 | // clampBound keeps End inside the row it describes so a persisted bound can be | 172 | // clampBound keeps End inside the row it describes so a persisted bound can be | ||
| 110 | // validated without consulting its row again. | 173 | // validated without consulting its row again. | ||
| 111 | func clampBound(b LineBound, cells int) LineBound { | 174 | func clampBound(b LineBound, cells int) LineBound { | ||
| @@ -124,26 +187,44 @@ func growBounds(bounds []LineBound, rows int) []LineBound { | |||||
| 124 | return out | 187 | return out | ||
| 125 | } | 188 | } | ||
| 126 | 189 | ||||
| 190 | + | func growRowIDs(ids []RowID, rows int) []RowID { | |||
| 191 | + | if len(ids) == rows { | |||
| 192 | + | return ids | |||
| 193 | + | } | |||
| 194 | + | out := make([]RowID, rows) | |||
| 195 | + | copy(out, ids) | |||
| 196 | + | return out | |||
| 197 | + | } | |||
| 198 | + | ||||
| 127 | func (h *History) sealTail() { | 199 | func (h *History) sealTail() { | ||
| 128 | if len(h.tail) == 0 { | 200 | if len(h.tail) == 0 { | ||
| 129 | return | 201 | return | ||
| 130 | } | 202 | } | ||
| 131 | - | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))}) | 203 | + | h.chunks = append(h.chunks, &HistoryChunk{ |
| 204 | + | rows: h.tail, | |||
| 205 | + | bounds: growBounds(h.tailBounds, len(h.tail)), | |||
| 206 | + | rowIDs: h.tailIDs, | |||
| 207 | + | }) | |||
| 132 | h.tail = nil | 208 | h.tail = nil | ||
| 133 | h.tailBounds = nil | 209 | h.tailBounds = nil | ||
| 210 | + | h.tailIDs = nil | |||
| 134 | } | 211 | } | ||
| 135 | 212 | ||||
| 136 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | 213 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | ||
| 137 | // chunkRows. Restored snapshots may have been written with a different chunk | 214 | // chunkRows. Restored snapshots may have been written with a different chunk | ||
| 138 | // size than the current history configuration. | 215 | // size than the current history configuration. | ||
| 139 | func (h *History) normalizeTail() { | 216 | func (h *History) normalizeTail() { | ||
| 217 | + | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)) | |||
| 140 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | 218 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | ||
| 219 | + | bounds := growBounds(h.tailBounds, len(h.tail)) | |||
| 141 | h.chunks = append(h.chunks, &HistoryChunk{ | 220 | h.chunks = append(h.chunks, &HistoryChunk{ | ||
| 142 | rows: h.tail[:h.chunkRows], | 221 | rows: h.tail[:h.chunkRows], | ||
| 143 | - | bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows], | 222 | + | bounds: bounds[:h.chunkRows], |
| 223 | + | rowIDs: h.tailIDs[:h.chunkRows], | |||
| 144 | }) | 224 | }) | ||
| 145 | h.tail = h.tail[h.chunkRows:] | 225 | h.tail = h.tail[h.chunkRows:] | ||
| 146 | - | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:] | 226 | + | h.tailBounds = bounds[h.chunkRows:] |
| 227 | + | h.tailIDs = h.tailIDs[h.chunkRows:] | |||
| 147 | } | 228 | } | ||
| 148 | } | 229 | } | ||
| 149 | 230 | ||||
| @@ -172,7 +253,13 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||||
| 172 | } else { | 253 | } else { | ||
| 173 | // Preserve cell storage while replacing only the chunk wrapper: a | 254 | // Preserve cell storage while replacing only the chunk wrapper: a | ||
| 174 | // retained view may still refer to the original immutable chunk. | 255 | // retained view may still refer to the original immutable chunk. | ||
| 175 | - | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]} | 256 | + | bounds := growBounds(chunk.bounds, len(chunk.rows)) |
| 257 | + | rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows)) | |||
| 258 | + | h.chunks[0] = &HistoryChunk{ | |||
| 259 | + | rows: chunk.rows[1:], | |||
| 260 | + | bounds: bounds[1:], | |||
| 261 | + | rowIDs: rowIDs[1:], | |||
| 262 | + | } | |||
| 176 | } | 263 | } | ||
| 177 | continue | 264 | continue | ||
| 178 | } | 265 | } | ||
| @@ -185,6 +272,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||||
| 185 | h.tail[0] = nil | 272 | h.tail[0] = nil | ||
| 186 | h.tail = h.tail[1:] | 273 | h.tail = h.tail[1:] | ||
| 187 | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | 274 | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | ||
| 275 | + | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | |||
| 188 | } | 276 | } | ||
| 189 | } | 277 | } | ||
| 190 | 278 | ||||
| @@ -208,6 +296,7 @@ func (h *History) View() HistoryView { | |||||
| 208 | chunks = append(chunks, &HistoryChunk{ | 296 | chunks = append(chunks, &HistoryChunk{ | ||
| 209 | rows: cloneHistoryRows(h.tail), | 297 | rows: cloneHistoryRows(h.tail), | ||
| 210 | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | 298 | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | ||
| 299 | + | rowIDs: growRowIDs(h.tailIDs, len(h.tail)), | |||
| 211 | }) | 300 | }) | ||
| 212 | } | 301 | } | ||
| 213 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | 302 | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | ||
| @@ -223,6 +312,7 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||||
| 223 | chunks: append([]*HistoryChunk(nil), h.chunks...), | 312 | chunks: append([]*HistoryChunk(nil), h.chunks...), | ||
| 224 | tail: cloneHistoryRows(h.tail), | 313 | tail: cloneHistoryRows(h.tail), | ||
| 225 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | 314 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | ||
| 315 | + | tailIDs: growRowIDs(h.tailIDs, len(h.tail)), | |||
| 226 | rows: h.rows, | 316 | rows: h.rows, | ||
| 227 | cells: h.cells, | 317 | cells: h.cells, | ||
| 228 | } | 318 | } | ||
| @@ -316,6 +406,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||||
| 316 | return nil | 406 | return nil | ||
| 317 | } | 407 | } | ||
| 318 | 408 | ||||
| 409 | + | // RowID returns the identity of the row at i, or zero when i is out of range. | |||
| 410 | + | func (v HistoryView) RowID(i int) RowID { | |||
| 411 | + | if i < 0 { | |||
| 412 | + | return 0 | |||
| 413 | + | } | |||
| 414 | + | for _, chunk := range v.chunks { | |||
| 415 | + | if i < len(chunk.rows) { | |||
| 416 | + | if i < len(chunk.rowIDs) { | |||
| 417 | + | return chunk.rowIDs[i] | |||
| 418 | + | } | |||
| 419 | + | return 0 | |||
| 420 | + | } | |||
| 421 | + | i -= len(chunk.rows) | |||
| 422 | + | } | |||
| 423 | + | return 0 | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // FindRowID returns the oldest-first index of id, or -1 when id is absent. | |||
| 427 | + | func (v HistoryView) FindRowID(id RowID) int { | |||
| 428 | + | if id == 0 { | |||
| 429 | + | return -1 | |||
| 430 | + | } | |||
| 431 | + | index := 0 | |||
| 432 | + | for _, chunk := range v.chunks { | |||
| 433 | + | for row := range chunk.rows { | |||
| 434 | + | if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id { | |||
| 435 | + | return index | |||
| 436 | + | } | |||
| 437 | + | index++ | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | return -1 | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // RowID returns the identity of the row within an immutable chunk. | |||
| 444 | + | func (c *HistoryChunk) RowID(i int) RowID { | |||
| 445 | + | if c == nil || i < 0 || i >= len(c.rowIDs) { | |||
| 446 | + | return 0 | |||
| 447 | + | } | |||
| 448 | + | return c.rowIDs[i] | |||
| 449 | + | } | |||
| 450 | + | ||||
| 319 | // Bound returns the logical extent of the row at i, or the zero value when i is | 451 | // Bound returns the logical extent of the row at i, or the zero value when i is | ||
| 320 | // out of range. A zero value describes a hard row whose End is not meaningful. | 452 | // out of range. A zero value describes a hard row whose End is not meaningful. | ||
| 321 | func (v HistoryView) Bound(i int) LineBound { | 453 | func (v HistoryView) Bound(i int) LineBound { | ||
| @@ -352,7 +484,7 @@ func (v HistorySnapshotView) Tail() HistoryView { | |||||
| 352 | return HistoryView{} | 484 | return HistoryView{} | ||
| 353 | } | 485 | } | ||
| 354 | return HistoryView{ | 486 | return HistoryView{ | ||
| 355 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}}, | 487 | + | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, |
| 356 | rows: len(v.tail), | 488 | rows: len(v.tail), | ||
| 357 | cells: historyRowsCells(v.tail), | 489 | cells: historyRowsCells(v.tail), | ||
| 358 | } | 490 | } | ||
M pkg/vt/history_codec.go +6 -1
| @@ -200,6 +200,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 200 | 200 | } | |
| 201 | 201 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |
| 202 | 202 | var chunks []*HistoryChunk | |
| 203 | + | var nextRowID RowID = 1 | |
| 203 | 204 | if populate { | |
| 204 | 205 | chunks = make([]*HistoryChunk, 0, chunkCount) | |
| 205 | 206 | } | |
| @@ -213,9 +214,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 213 | 214 | ||
| 214 | 215 | var rows [][]renderer.Cell | |
| 215 | 216 | var bounds []LineBound | |
| 217 | + | var rowIDs []RowID | |
| 216 | 218 | if populate { | |
| 217 | 219 | rows = make([][]renderer.Cell, 0, rowCount) | |
| 218 | 220 | bounds = make([]LineBound, 0, rowCount) | |
| 221 | + | rowIDs = make([]RowID, 0, rowCount) | |
| 219 | 222 | } | |
| 220 | 223 | for range rowCount { | |
| 221 | 224 | cellCount, ok := p.uint32() | |
| @@ -248,10 +251,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 248 | 251 | if populate { | |
| 249 | 252 | rows = append(rows, row) | |
| 250 | 253 | bounds = append(bounds, bound) | |
| 254 | + | rowIDs = append(rowIDs, nextRowID) | |
| 255 | + | nextRowID++ | |
| 251 | 256 | } | |
| 252 | 257 | } | |
| 253 | 258 | if populate { | |
| 254 | - | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds}) | |
| 259 | + | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | |
| 255 | 260 | } | |
| 256 | 261 | } | |
| 257 | 262 | if len(p.data) != 0 { | |
| @@ -200,6 +200,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 200 | } | 200 | } | ||
| 201 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | 201 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | ||
| 202 | var chunks []*HistoryChunk | 202 | var chunks []*HistoryChunk | ||
| 203 | + | var nextRowID RowID = 1 | |||
| 203 | if populate { | 204 | if populate { | ||
| 204 | chunks = make([]*HistoryChunk, 0, chunkCount) | 205 | chunks = make([]*HistoryChunk, 0, chunkCount) | ||
| 205 | } | 206 | } | ||
| @@ -213,9 +214,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 213 | 214 | ||||
| 214 | var rows [][]renderer.Cell | 215 | var rows [][]renderer.Cell | ||
| 215 | var bounds []LineBound | 216 | var bounds []LineBound | ||
| 217 | + | var rowIDs []RowID | |||
| 216 | if populate { | 218 | if populate { | ||
| 217 | rows = make([][]renderer.Cell, 0, rowCount) | 219 | rows = make([][]renderer.Cell, 0, rowCount) | ||
| 218 | bounds = make([]LineBound, 0, rowCount) | 220 | bounds = make([]LineBound, 0, rowCount) | ||
| 221 | + | rowIDs = make([]RowID, 0, rowCount) | |||
| 219 | } | 222 | } | ||
| 220 | for range rowCount { | 223 | for range rowCount { | ||
| 221 | cellCount, ok := p.uint32() | 224 | cellCount, ok := p.uint32() | ||
| @@ -248,10 +251,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 248 | if populate { | 251 | if populate { | ||
| 249 | rows = append(rows, row) | 252 | rows = append(rows, row) | ||
| 250 | bounds = append(bounds, bound) | 253 | bounds = append(bounds, bound) | ||
| 254 | + | rowIDs = append(rowIDs, nextRowID) | |||
| 255 | + | nextRowID++ | |||
| 251 | } | 256 | } | ||
| 252 | } | 257 | } | ||
| 253 | if populate { | 258 | if populate { | ||
| 254 | - | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds}) | 259 | + | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) |
| 255 | } | 260 | } | ||
| 256 | } | 261 | } | ||
| 257 | if len(p.data) != 0 { | 262 | if len(p.data) != 0 { | ||
A pkg/vt/row_id.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | // RowID identifies one physical terminal row for the lifetime of a Screen. | |
| 4 | + | // Row IDs are never zero and are never reused by their owning Screen. | |
| 5 | + | type RowID uint64 | |
| 6 | + | ||
| 7 | + | func (s *Screen) nextRowIDValue() RowID { | |
| 8 | + | if s.nextRowID == ^RowID(0) { | |
| 9 | + | panic("vt: row ID space exhausted") | |
| 10 | + | } | |
| 11 | + | s.nextRowID++ | |
| 12 | + | return s.nextRowID | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (s *Screen) newBuffer(width, height int) *buffer { | |
| 16 | + | b := newBuffer(width, height) | |
| 17 | + | for i := range b.rowIDs { | |
| 18 | + | b.rowIDs[i] = s.nextRowIDValue() | |
| 19 | + | } | |
| 20 | + | return b | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (s *Screen) fillMissingRowIDs(b *buffer) { | |
| 24 | + | if b == nil { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | for i := range b.rowIDs { | |
| 28 | + | if b.rowIDs[i] == 0 { | |
| 29 | + | b.rowIDs[i] = s.nextRowIDValue() | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // RowIDs returns an owned copy of the active live grid's row identities. | |
| 35 | + | func (s *Screen) RowIDs() []RowID { | |
| 36 | + | if s == nil || s.buffer == nil { | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | return append([]RowID(nil), s.buffer.rowIDs...) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // RowID returns the identity of active live row y, or zero when y is out of | |
| 43 | + | // range. | |
| 44 | + | func (s *Screen) RowID(y int) RowID { | |
| 45 | + | if s == nil || s.buffer == nil || y < 0 || y >= len(s.buffer.rowIDs) { | |
| 46 | + | return 0 | |
| 47 | + | } | |
| 48 | + | return s.buffer.rowIDs[y] | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | // RowID identifies one physical terminal row for the lifetime of a Screen. | |||
| 4 | + | // Row IDs are never zero and are never reused by their owning Screen. | |||
| 5 | + | type RowID uint64 | |||
| 6 | + | ||||
| 7 | + | func (s *Screen) nextRowIDValue() RowID { | |||
| 8 | + | if s.nextRowID == ^RowID(0) { | |||
| 9 | + | panic("vt: row ID space exhausted") | |||
| 10 | + | } | |||
| 11 | + | s.nextRowID++ | |||
| 12 | + | return s.nextRowID | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (s *Screen) newBuffer(width, height int) *buffer { | |||
| 16 | + | b := newBuffer(width, height) | |||
| 17 | + | for i := range b.rowIDs { | |||
| 18 | + | b.rowIDs[i] = s.nextRowIDValue() | |||
| 19 | + | } | |||
| 20 | + | return b | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (s *Screen) fillMissingRowIDs(b *buffer) { | |||
| 24 | + | if b == nil { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | for i := range b.rowIDs { | |||
| 28 | + | if b.rowIDs[i] == 0 { | |||
| 29 | + | b.rowIDs[i] = s.nextRowIDValue() | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // RowIDs returns an owned copy of the active live grid's row identities. | |||
| 35 | + | func (s *Screen) RowIDs() []RowID { | |||
| 36 | + | if s == nil || s.buffer == nil { | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | return append([]RowID(nil), s.buffer.rowIDs...) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // RowID returns the identity of active live row y, or zero when y is out of | |||
| 43 | + | // range. | |||
| 44 | + | func (s *Screen) RowID(y int) RowID { | |||
| 45 | + | if s == nil || s.buffer == nil || y < 0 || y >= len(s.buffer.rowIDs) { | |||
| 46 | + | return 0 | |||
| 47 | + | } | |||
| 48 | + | return s.buffer.rowIDs[y] | |||
| 49 | + | } | |||
A pkg/vt/row_id_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestScreenRowIDsRotateWithRowsAndHistory(t *testing.T) { | |
| 6 | + | s := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 7 | + | initial := s.RowIDs() | |
| 8 | + | assertUniqueNonzeroRowIDs(t, initial) | |
| 9 | + | ||
| 10 | + | s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n")) | |
| 11 | + | ||
| 12 | + | live := s.RowIDs() | |
| 13 | + | if got, want := live, []RowID{initial[1], initial[2], live[2]}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { | |
| 14 | + | t.Fatalf("live row IDs = %v, want rows moved from %v with a fresh blank row", got, initial) | |
| 15 | + | } | |
| 16 | + | if live[2] == initial[0] || live[2] == initial[1] || live[2] == initial[2] { | |
| 17 | + | t.Fatalf("new blank row reused a row ID: live=%v initial=%v", live, initial) | |
| 18 | + | } | |
| 19 | + | assertUniqueNonzeroRowIDs(t, live) | |
| 20 | + | ||
| 21 | + | view := s.History().SealAndView() | |
| 22 | + | if got, want := view.RowID(0), initial[0]; got != want { | |
| 23 | + | t.Fatalf("evicted row ID = %d, want %d", got, want) | |
| 24 | + | } | |
| 25 | + | if got := view.FindRowID(initial[0]); got != 0 { | |
| 26 | + | t.Fatalf("FindRowID(%d) = %d, want 0", initial[0], got) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) { | |
| 31 | + | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8}) | |
| 32 | + | s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD")) | |
| 33 | + | s.Write([]byte("\x1b[2;4r\x1b[4;1H\n")) | |
| 34 | + | if got := s.History().Len(); got != 0 { | |
| 35 | + | t.Fatalf("interior scroll history rows = %d, want 0", got) | |
| 36 | + | } | |
| 37 | + | assertUniqueNonzeroRowIDs(t, s.RowIDs()) | |
| 38 | + | primary := s.RowIDs() | |
| 39 | + | ||
| 40 | + | s.Write([]byte("\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\r\n")) | |
| 41 | + | if got := s.History().Len(); got != 0 { | |
| 42 | + | t.Fatalf("alternate scroll history rows = %d, want 0", got) | |
| 43 | + | } | |
| 44 | + | s.Write([]byte("\x1b[?1049l")) | |
| 45 | + | for i, id := range primary { | |
| 46 | + | if got := s.RowID(i); got != id { | |
| 47 | + | t.Fatalf("restored primary row %d ID = %d, want %d", i, got, id) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |
| 53 | + | s := NewScreen(4, 3) | |
| 54 | + | before := s.RowIDs() | |
| 55 | + | s.Write([]byte("AAAA\x1b[2J")) | |
| 56 | + | cleared := s.RowIDs() | |
| 57 | + | assertFreshRowIDs(t, before, cleared) | |
| 58 | + | ||
| 59 | + | s.Write([]byte("\x1bc")) | |
| 60 | + | reset := s.RowIDs() | |
| 61 | + | assertFreshRowIDs(t, cleared, reset) | |
| 62 | + | ||
| 63 | + | s.Write([]byte("BBBB\x1b[?1049h")) | |
| 64 | + | alternate := s.RowIDs() | |
| 65 | + | assertFreshRowIDs(t, reset, alternate) | |
| 66 | + | s.Write([]byte("CCCC\x1b[?1049l")) | |
| 67 | + | for i, id := range reset { | |
| 68 | + | if got := s.RowID(i); got != id { | |
| 69 | + | t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |
| 75 | + | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | |
| 76 | + | for i, text := range []string{"one", "two"} { | |
| 77 | + | if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | before := h.SealAndView() | |
| 82 | + | if got, want := before.RowID(0), RowID(1); got != want { | |
| 83 | + | t.Fatalf("sealed row ID = %d, want %d", got, want) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | if got := before.FindRowID(2); got != 1 { | |
| 90 | + | t.Fatalf("retained view FindRowID(2) = %d, want 1", got) | |
| 91 | + | } | |
| 92 | + | after := h.SealAndView() | |
| 93 | + | if got := after.FindRowID(3); got != 2 { | |
| 94 | + | t.Fatalf("new view FindRowID(3) = %d, want 2", got) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | if got := before.FindRowID(1); got != 0 { | |
| 101 | + | t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got) | |
| 102 | + | } | |
| 103 | + | if got := h.View().FindRowID(1); got != -1 { | |
| 104 | + | t.Fatalf("evicted current FindRowID(1) = %d, want -1", got) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) { | |
| 109 | + | t.Helper() | |
| 110 | + | seen := make(map[RowID]struct{}, len(ids)) | |
| 111 | + | for _, id := range ids { | |
| 112 | + | if id == 0 { | |
| 113 | + | t.Fatal("row ID is zero") | |
| 114 | + | } | |
| 115 | + | if _, ok := seen[id]; ok { | |
| 116 | + | t.Fatalf("duplicate row ID %d in %v", id, ids) | |
| 117 | + | } | |
| 118 | + | seen[id] = struct{}{} | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func assertFreshRowIDs(t *testing.T, before, after []RowID) { | |
| 123 | + | t.Helper() | |
| 124 | + | assertUniqueNonzeroRowIDs(t, after) | |
| 125 | + | seen := make(map[RowID]struct{}, len(before)) | |
| 126 | + | for _, id := range before { | |
| 127 | + | seen[id] = struct{}{} | |
| 128 | + | } | |
| 129 | + | for _, id := range after { | |
| 130 | + | if _, ok := seen[id]; ok { | |
| 131 | + | t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestScreenRowIDsRotateWithRowsAndHistory(t *testing.T) { | |||
| 6 | + | s := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 7 | + | initial := s.RowIDs() | |||
| 8 | + | assertUniqueNonzeroRowIDs(t, initial) | |||
| 9 | + | ||||
| 10 | + | s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n")) | |||
| 11 | + | ||||
| 12 | + | live := s.RowIDs() | |||
| 13 | + | if got, want := live, []RowID{initial[1], initial[2], live[2]}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { | |||
| 14 | + | t.Fatalf("live row IDs = %v, want rows moved from %v with a fresh blank row", got, initial) | |||
| 15 | + | } | |||
| 16 | + | if live[2] == initial[0] || live[2] == initial[1] || live[2] == initial[2] { | |||
| 17 | + | t.Fatalf("new blank row reused a row ID: live=%v initial=%v", live, initial) | |||
| 18 | + | } | |||
| 19 | + | assertUniqueNonzeroRowIDs(t, live) | |||
| 20 | + | ||||
| 21 | + | view := s.History().SealAndView() | |||
| 22 | + | if got, want := view.RowID(0), initial[0]; got != want { | |||
| 23 | + | t.Fatalf("evicted row ID = %d, want %d", got, want) | |||
| 24 | + | } | |||
| 25 | + | if got := view.FindRowID(initial[0]); got != 0 { | |||
| 26 | + | t.Fatalf("FindRowID(%d) = %d, want 0", initial[0], got) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) { | |||
| 31 | + | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8}) | |||
| 32 | + | s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD")) | |||
| 33 | + | s.Write([]byte("\x1b[2;4r\x1b[4;1H\n")) | |||
| 34 | + | if got := s.History().Len(); got != 0 { | |||
| 35 | + | t.Fatalf("interior scroll history rows = %d, want 0", got) | |||
| 36 | + | } | |||
| 37 | + | assertUniqueNonzeroRowIDs(t, s.RowIDs()) | |||
| 38 | + | primary := s.RowIDs() | |||
| 39 | + | ||||
| 40 | + | s.Write([]byte("\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\r\n")) | |||
| 41 | + | if got := s.History().Len(); got != 0 { | |||
| 42 | + | t.Fatalf("alternate scroll history rows = %d, want 0", got) | |||
| 43 | + | } | |||
| 44 | + | s.Write([]byte("\x1b[?1049l")) | |||
| 45 | + | for i, id := range primary { | |||
| 46 | + | if got := s.RowID(i); got != id { | |||
| 47 | + | t.Fatalf("restored primary row %d ID = %d, want %d", i, got, id) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |||
| 53 | + | s := NewScreen(4, 3) | |||
| 54 | + | before := s.RowIDs() | |||
| 55 | + | s.Write([]byte("AAAA\x1b[2J")) | |||
| 56 | + | cleared := s.RowIDs() | |||
| 57 | + | assertFreshRowIDs(t, before, cleared) | |||
| 58 | + | ||||
| 59 | + | s.Write([]byte("\x1bc")) | |||
| 60 | + | reset := s.RowIDs() | |||
| 61 | + | assertFreshRowIDs(t, cleared, reset) | |||
| 62 | + | ||||
| 63 | + | s.Write([]byte("BBBB\x1b[?1049h")) | |||
| 64 | + | alternate := s.RowIDs() | |||
| 65 | + | assertFreshRowIDs(t, reset, alternate) | |||
| 66 | + | s.Write([]byte("CCCC\x1b[?1049l")) | |||
| 67 | + | for i, id := range reset { | |||
| 68 | + | if got := s.RowID(i); got != id { | |||
| 69 | + | t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |||
| 75 | + | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | |||
| 76 | + | for i, text := range []string{"one", "two"} { | |||
| 77 | + | if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | before := h.SealAndView() | |||
| 82 | + | if got, want := before.RowID(0), RowID(1); got != want { | |||
| 83 | + | t.Fatalf("sealed row ID = %d, want %d", got, want) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | if got := before.FindRowID(2); got != 1 { | |||
| 90 | + | t.Fatalf("retained view FindRowID(2) = %d, want 1", got) | |||
| 91 | + | } | |||
| 92 | + | after := h.SealAndView() | |||
| 93 | + | if got := after.FindRowID(3); got != 2 { | |||
| 94 | + | t.Fatalf("new view FindRowID(3) = %d, want 2", got) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | if got := before.FindRowID(1); got != 0 { | |||
| 101 | + | t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got) | |||
| 102 | + | } | |||
| 103 | + | if got := h.View().FindRowID(1); got != -1 { | |||
| 104 | + | t.Fatalf("evicted current FindRowID(1) = %d, want -1", got) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) { | |||
| 109 | + | t.Helper() | |||
| 110 | + | seen := make(map[RowID]struct{}, len(ids)) | |||
| 111 | + | for _, id := range ids { | |||
| 112 | + | if id == 0 { | |||
| 113 | + | t.Fatal("row ID is zero") | |||
| 114 | + | } | |||
| 115 | + | if _, ok := seen[id]; ok { | |||
| 116 | + | t.Fatalf("duplicate row ID %d in %v", id, ids) | |||
| 117 | + | } | |||
| 118 | + | seen[id] = struct{}{} | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func assertFreshRowIDs(t *testing.T, before, after []RowID) { | |||
| 123 | + | t.Helper() | |||
| 124 | + | assertUniqueNonzeroRowIDs(t, after) | |||
| 125 | + | seen := make(map[RowID]struct{}, len(before)) | |||
| 126 | + | for _, id := range before { | |||
| 127 | + | seen[id] = struct{}{} | |||
| 128 | + | } | |||
| 129 | + | for _, id := range after { | |||
| 130 | + | if _, ok := seen[id]; ok { | |||
| 131 | + | t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | } | |||
M pkg/vt/screen.go +4 -3
| @@ -53,6 +53,8 @@ type Screen struct { | |||
| 53 | 53 | history *History | |
| 54 | 54 | buffer *buffer | |
| 55 | 55 | ||
| 56 | + | nextRowID RowID | |
| 57 | + | ||
| 56 | 58 | defaultFG renderer.RGB | |
| 57 | 59 | defaultBG renderer.RGB | |
| 58 | 60 | defaultColorsKnown bool | |
| @@ -86,15 +88,14 @@ type Screen struct { | |||
| 86 | 88 | } | |
| 87 | 89 | ||
| 88 | 90 | func NewScreen(width, height int) *Screen { | |
| 89 | - | b := newBuffer(width, height) | |
| 90 | 91 | s := &Screen{ | |
| 91 | - | Frame: b.frame, | |
| 92 | - | buffer: b, | |
| 93 | 92 | Style: renderer.DefaultStyle(), | |
| 94 | 93 | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 95 | 94 | damageGeneration: 1, | |
| 96 | 95 | cursorVisible: true, | |
| 97 | 96 | } | |
| 97 | + | s.buffer = s.newBuffer(width, height) | |
| 98 | + | s.Frame = s.buffer.frame | |
| 98 | 99 | s.resetScrollRegion() | |
| 99 | 100 | return s | |
| 100 | 101 | } | |
| @@ -53,6 +53,8 @@ type Screen struct { | |||||
| 53 | history *History | 53 | history *History | ||
| 54 | buffer *buffer | 54 | buffer *buffer | ||
| 55 | 55 | ||||
| 56 | + | nextRowID RowID | |||
| 57 | + | ||||
| 56 | defaultFG renderer.RGB | 58 | defaultFG renderer.RGB | ||
| 57 | defaultBG renderer.RGB | 59 | defaultBG renderer.RGB | ||
| 58 | defaultColorsKnown bool | 60 | defaultColorsKnown bool | ||
| @@ -86,15 +88,14 @@ type Screen struct { | |||||
| 86 | } | 88 | } | ||
| 87 | 89 | ||||
| 88 | func NewScreen(width, height int) *Screen { | 90 | func NewScreen(width, height int) *Screen { | ||
| 89 | - | b := newBuffer(width, height) | |||
| 90 | s := &Screen{ | 91 | s := &Screen{ | ||
| 91 | - | Frame: b.frame, | |||
| 92 | - | buffer: b, | |||
| 93 | Style: renderer.DefaultStyle(), | 92 | Style: renderer.DefaultStyle(), | ||
| 94 | damage: []renderer.Damage{renderer.FullRedraw()}, | 93 | damage: []renderer.Damage{renderer.FullRedraw()}, | ||
| 95 | damageGeneration: 1, | 94 | damageGeneration: 1, | ||
| 96 | cursorVisible: true, | 95 | cursorVisible: true, | ||
| 97 | } | 96 | } | ||
| 97 | + | s.buffer = s.newBuffer(width, height) | |||
| 98 | + | s.Frame = s.buffer.frame | |||
| 98 | s.resetScrollRegion() | 99 | s.resetScrollRegion() | ||
| 99 | return s | 100 | return s | ||
| 100 | } | 101 | } | ||
M pkg/vt/screen_csi.go +1 -0
| @@ -412,6 +412,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 412 | 412 | } | |
| 413 | 413 | for y := range s.buffer.boundaries { | |
| 414 | 414 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | |
| 415 | + | s.buffer.rowIDs[y] = s.nextRowIDValue() | |
| 415 | 416 | } | |
| 416 | 417 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |
| 417 | 418 | default: | |
| @@ -412,6 +412,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||||
| 412 | } | 412 | } | ||
| 413 | for y := range s.buffer.boundaries { | 413 | for y := range s.buffer.boundaries { | ||
| 414 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | 414 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | ||
| 415 | + | s.buffer.rowIDs[y] = s.nextRowIDValue() | |||
| 415 | } | 416 | } | ||
| 416 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | 417 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | ||
| 417 | default: | 418 | default: | ||
M pkg/vt/screen_modes.go +3 -2
| @@ -87,7 +87,7 @@ func (s *Screen) colorSchemeReport() []byte { | |||
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | func (s *Screen) reset() { | |
| 90 | - | s.buffer = newBuffer(s.Frame.Width, s.Frame.Height) | |
| 90 | + | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | |
| 91 | 91 | s.Frame = s.buffer.frame | |
| 92 | 92 | s.Row, s.Col = 0, 0 | |
| 93 | 93 | s.Style = renderer.DefaultStyle() | |
| @@ -203,7 +203,7 @@ func (s *Screen) enterAlternateScreen() { | |||
| 203 | 203 | insertMode: s.insertMode, | |
| 204 | 204 | } | |
| 205 | 205 | } | |
| 206 | - | s.buffer = newBuffer(s.Frame.Width, s.Frame.Height) | |
| 206 | + | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | |
| 207 | 207 | s.Frame = s.buffer.frame | |
| 208 | 208 | s.Row, s.Col = 0, 0 | |
| 209 | 209 | s.Style = renderer.DefaultStyle() | |
| @@ -220,6 +220,7 @@ func (s *Screen) exitAlternateScreen() { | |||
| 220 | 220 | s.buffer = state.buffer | |
| 221 | 221 | if s.buffer == nil { | |
| 222 | 222 | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | |
| 223 | + | s.fillMissingRowIDs(s.buffer) | |
| 223 | 224 | } | |
| 224 | 225 | s.Frame = s.buffer.frame | |
| 225 | 226 | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |
| @@ -87,7 +87,7 @@ func (s *Screen) colorSchemeReport() []byte { | |||||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | func (s *Screen) reset() { | 89 | func (s *Screen) reset() { | ||
| 90 | - | s.buffer = newBuffer(s.Frame.Width, s.Frame.Height) | 90 | + | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) |
| 91 | s.Frame = s.buffer.frame | 91 | s.Frame = s.buffer.frame | ||
| 92 | s.Row, s.Col = 0, 0 | 92 | s.Row, s.Col = 0, 0 | ||
| 93 | s.Style = renderer.DefaultStyle() | 93 | s.Style = renderer.DefaultStyle() | ||
| @@ -203,7 +203,7 @@ func (s *Screen) enterAlternateScreen() { | |||||
| 203 | insertMode: s.insertMode, | 203 | insertMode: s.insertMode, | ||
| 204 | } | 204 | } | ||
| 205 | } | 205 | } | ||
| 206 | - | s.buffer = newBuffer(s.Frame.Width, s.Frame.Height) | 206 | + | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) |
| 207 | s.Frame = s.buffer.frame | 207 | s.Frame = s.buffer.frame | ||
| 208 | s.Row, s.Col = 0, 0 | 208 | s.Row, s.Col = 0, 0 | ||
| 209 | s.Style = renderer.DefaultStyle() | 209 | s.Style = renderer.DefaultStyle() | ||
| @@ -220,6 +220,7 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 220 | s.buffer = state.buffer | 220 | s.buffer = state.buffer | ||
| 221 | if s.buffer == nil { | 221 | if s.buffer == nil { | ||
| 222 | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | 222 | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | ||
| 223 | + | s.fillMissingRowIDs(s.buffer) | |||
| 223 | } | 224 | } | ||
| 224 | s.Frame = s.buffer.frame | 225 | s.Frame = s.buffer.frame | ||
| 225 | s.Row = clamp(state.row, 0, s.Frame.Height-1) | 226 | s.Row = clamp(state.row, 0, s.Frame.Height-1) | ||
M pkg/vt/screen_resize.go +11 -4
| @@ -10,10 +10,10 @@ func (s *Screen) Resize(width, height int) { | |||
| 10 | 10 | // renderer.Frame permits a collapsed geometry. There is no layout to | |
| 11 | 11 | // preserve until a usable viewport is supplied again. | |
| 12 | 12 | width, height = max(width, 0), max(height, 0) | |
| 13 | - | s.buffer = newBuffer(width, height) | |
| 13 | + | s.buffer = s.newBuffer(width, height) | |
| 14 | 14 | s.Row, s.Col = 0, 0 | |
| 15 | 15 | if state := s.alternate; state != nil { | |
| 16 | - | state.buffer = newBuffer(width, height) | |
| 16 | + | state.buffer = s.newBuffer(width, height) | |
| 17 | 17 | state.frame = state.buffer.frame | |
| 18 | 18 | state.row, state.col = 0, 0 | |
| 19 | 19 | state.scrollTop, state.scrollBottom = 0, height-1 | |
| @@ -25,10 +25,16 @@ func (s *Screen) Resize(width, height int) { | |||
| 25 | 25 | if saved != nil && saved.saved { | |
| 26 | 26 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | |
| 27 | 27 | } | |
| 28 | - | evicted, evictedBounds := b.resize(width, height, active, savedPoint) | |
| 28 | + | evicted, evictedBounds, evictedIDs := b.resize(width, height, active, savedPoint) | |
| 29 | + | for i := range evictedIDs { | |
| 30 | + | if evictedIDs[i] == 0 { | |
| 31 | + | evictedIDs[i] = s.nextRowIDValue() | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | s.fillMissingRowIDs(b) | |
| 29 | 35 | if evict { | |
| 30 | 36 | for i, line := range evicted { | |
| 31 | - | s.recordEvicted(line, evictedBounds[i]) | |
| 37 | + | s.recordEvicted(line, evictedBounds[i], evictedIDs[i]) | |
| 32 | 38 | } | |
| 33 | 39 | } | |
| 34 | 40 | if savedPoint != nil { | |
| @@ -44,6 +50,7 @@ func (s *Screen) Resize(width, height int) { | |||
| 44 | 50 | state := s.alternate | |
| 45 | 51 | if state.buffer == nil { | |
| 46 | 52 | state.buffer = bufferFromFrame(state.frame) | |
| 53 | + | s.fillMissingRowIDs(state.buffer) | |
| 47 | 54 | } | |
| 48 | 55 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | |
| 49 | 56 | state.frame = state.buffer.frame | |
| @@ -10,10 +10,10 @@ func (s *Screen) Resize(width, height int) { | |||||
| 10 | // renderer.Frame permits a collapsed geometry. There is no layout to | 10 | // renderer.Frame permits a collapsed geometry. There is no layout to | ||
| 11 | // preserve until a usable viewport is supplied again. | 11 | // preserve until a usable viewport is supplied again. | ||
| 12 | width, height = max(width, 0), max(height, 0) | 12 | width, height = max(width, 0), max(height, 0) | ||
| 13 | - | s.buffer = newBuffer(width, height) | 13 | + | s.buffer = s.newBuffer(width, height) |
| 14 | s.Row, s.Col = 0, 0 | 14 | s.Row, s.Col = 0, 0 | ||
| 15 | if state := s.alternate; state != nil { | 15 | if state := s.alternate; state != nil { | ||
| 16 | - | state.buffer = newBuffer(width, height) | 16 | + | state.buffer = s.newBuffer(width, height) |
| 17 | state.frame = state.buffer.frame | 17 | state.frame = state.buffer.frame | ||
| 18 | state.row, state.col = 0, 0 | 18 | state.row, state.col = 0, 0 | ||
| 19 | state.scrollTop, state.scrollBottom = 0, height-1 | 19 | state.scrollTop, state.scrollBottom = 0, height-1 | ||
| @@ -25,10 +25,16 @@ func (s *Screen) Resize(width, height int) { | |||||
| 25 | if saved != nil && saved.saved { | 25 | if saved != nil && saved.saved { | ||
| 26 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | 26 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | ||
| 27 | } | 27 | } | ||
| 28 | - | evicted, evictedBounds := b.resize(width, height, active, savedPoint) | 28 | + | evicted, evictedBounds, evictedIDs := b.resize(width, height, active, savedPoint) |
| 29 | + | for i := range evictedIDs { | |||
| 30 | + | if evictedIDs[i] == 0 { | |||
| 31 | + | evictedIDs[i] = s.nextRowIDValue() | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | s.fillMissingRowIDs(b) | |||
| 29 | if evict { | 35 | if evict { | ||
| 30 | for i, line := range evicted { | 36 | for i, line := range evicted { | ||
| 31 | - | s.recordEvicted(line, evictedBounds[i]) | 37 | + | s.recordEvicted(line, evictedBounds[i], evictedIDs[i]) |
| 32 | } | 38 | } | ||
| 33 | } | 39 | } | ||
| 34 | if savedPoint != nil { | 40 | if savedPoint != nil { | ||
| @@ -44,6 +50,7 @@ func (s *Screen) Resize(width, height int) { | |||||
| 44 | state := s.alternate | 50 | state := s.alternate | ||
| 45 | if state.buffer == nil { | 51 | if state.buffer == nil { | ||
| 46 | state.buffer = bufferFromFrame(state.frame) | 52 | state.buffer = bufferFromFrame(state.frame) | ||
| 53 | + | s.fillMissingRowIDs(state.buffer) | |||
| 47 | } | 54 | } | ||
| 48 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | 55 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | ||
| 49 | state.frame = state.buffer.frame | 56 | state.frame = state.buffer.frame | ||
M pkg/vt/screen_scroll.go +7 -5
| @@ -86,6 +86,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||
| 86 | 86 | s.emitLineEvicted(top, n) | |
| 87 | 87 | s.Frame.ScrollUp(top, bottom, n) | |
| 88 | 88 | s.buffer.scrollUp(top, bottom, n) | |
| 89 | + | s.fillMissingRowIDs(s.buffer) | |
| 89 | 90 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 90 | 91 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 91 | 92 | return bottom-n >= top | |
| @@ -97,16 +98,16 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 97 | 98 | if s.alternate != nil || top != 0 { | |
| 98 | 99 | return | |
| 99 | 100 | } | |
| 100 | - | // Read boundaries before the caller rotates the frame: a soft link belongs | |
| 101 | - | // to the row it follows, and rotation reassigns row indices. | |
| 101 | + | // Read boundaries and IDs before the caller rotates the frame: a soft link | |
| 102 | + | // belongs to the row it follows, and rotation reassigns row indices. | |
| 102 | 103 | for y := top; y < top+n; y++ { | |
| 103 | - | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y)) | |
| 104 | + | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) | |
| 104 | 105 | } | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 107 | - | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) { | |
| 108 | + | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | |
| 108 | 109 | if s.history != nil { | |
| 109 | - | err := s.history.Append(row, bound) | |
| 110 | + | err := s.history.Append(row, bound, id) | |
| 110 | 111 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |
| 111 | 112 | panic(err) | |
| 112 | 113 | } | |
| @@ -134,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 134 | 135 | // Full-width region: rotate line offsets instead of copying cells. | |
| 135 | 136 | s.Frame.ScrollDown(top, bottom, n) | |
| 136 | 137 | s.buffer.scrollDown(top, bottom, n) | |
| 138 | + | s.fillMissingRowIDs(s.buffer) | |
| 137 | 139 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 138 | 140 | } | |
| 139 | 141 | ||
| @@ -86,6 +86,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||||
| 86 | s.emitLineEvicted(top, n) | 86 | s.emitLineEvicted(top, n) | ||
| 87 | s.Frame.ScrollUp(top, bottom, n) | 87 | s.Frame.ScrollUp(top, bottom, n) | ||
| 88 | s.buffer.scrollUp(top, bottom, n) | 88 | s.buffer.scrollUp(top, bottom, n) | ||
| 89 | + | s.fillMissingRowIDs(s.buffer) | |||
| 89 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | 90 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | ||
| 90 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | 91 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | ||
| 91 | return bottom-n >= top | 92 | return bottom-n >= top | ||
| @@ -97,16 +98,16 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 97 | if s.alternate != nil || top != 0 { | 98 | if s.alternate != nil || top != 0 { | ||
| 98 | return | 99 | return | ||
| 99 | } | 100 | } | ||
| 100 | - | // Read boundaries before the caller rotates the frame: a soft link belongs | 101 | + | // Read boundaries and IDs before the caller rotates the frame: a soft link |
| 101 | - | // to the row it follows, and rotation reassigns row indices. | 102 | + | // belongs to the row it follows, and rotation reassigns row indices. |
| 102 | for y := top; y < top+n; y++ { | 103 | for y := top; y < top+n; y++ { | ||
| 103 | - | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y)) | 104 | + | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) |
| 104 | } | 105 | } | ||
| 105 | } | 106 | } | ||
| 106 | 107 | ||||
| 107 | - | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) { | 108 | + | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { |
| 108 | if s.history != nil { | 109 | if s.history != nil { | ||
| 109 | - | err := s.history.Append(row, bound) | 110 | + | err := s.history.Append(row, bound, id) |
| 110 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | 111 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | ||
| 111 | panic(err) | 112 | panic(err) | ||
| 112 | } | 113 | } | ||
| @@ -134,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||||
| 134 | // Full-width region: rotate line offsets instead of copying cells. | 135 | // Full-width region: rotate line offsets instead of copying cells. | ||
| 135 | s.Frame.ScrollDown(top, bottom, n) | 136 | s.Frame.ScrollDown(top, bottom, n) | ||
| 136 | s.buffer.scrollDown(top, bottom, n) | 137 | s.buffer.scrollDown(top, bottom, n) | ||
| 138 | + | s.fillMissingRowIDs(s.buffer) | |||
| 137 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | 139 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | ||
| 138 | } | 140 | } | ||
| 139 | 141 | ||||
M pkg/vt/screen_write.go +4 -0
| @@ -184,6 +184,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 184 | 184 | if y < 0 || y >= s.Frame.Height { | |
| 185 | 185 | return x0, 0 | |
| 186 | 186 | } | |
| 187 | + | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |
| 187 | 188 | if x0 < 0 { | |
| 188 | 189 | x0 = 0 | |
| 189 | 190 | } | |
| @@ -207,6 +208,9 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 207 | 208 | s.Frame.Set(x, y, blank) | |
| 208 | 209 | } | |
| 209 | 210 | s.buffer.clear(y, x0, x1) | |
| 211 | + | if fullRow { | |
| 212 | + | s.buffer.rowIDs[y] = s.nextRowIDValue() | |
| 213 | + | } | |
| 210 | 214 | return x0, x1 - x0 | |
| 211 | 215 | } | |
| 212 | 216 | ||
| @@ -184,6 +184,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||||
| 184 | if y < 0 || y >= s.Frame.Height { | 184 | if y < 0 || y >= s.Frame.Height { | ||
| 185 | return x0, 0 | 185 | return x0, 0 | ||
| 186 | } | 186 | } | ||
| 187 | + | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |||
| 187 | if x0 < 0 { | 188 | if x0 < 0 { | ||
| 188 | x0 = 0 | 189 | x0 = 0 | ||
| 189 | } | 190 | } | ||
| @@ -207,6 +208,9 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||||
| 207 | s.Frame.Set(x, y, blank) | 208 | s.Frame.Set(x, y, blank) | ||
| 208 | } | 209 | } | ||
| 209 | s.buffer.clear(y, x0, x1) | 210 | s.buffer.clear(y, x0, x1) | ||
| 211 | + | if fullRow { | |||
| 212 | + | s.buffer.rowIDs[y] = s.nextRowIDValue() | |||
| 213 | + | } | |||
| 210 | return x0, x1 - x0 | 214 | return x0, x1 - x0 | ||
| 211 | } | 215 | } | ||
| 212 | 216 | ||||
M pkg/vt/snapshot.go +9 -0
| @@ -83,6 +83,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 83 | 83 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |
| 84 | 84 | } | |
| 85 | 85 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 86 | + | h.assignChunkIDs(view.chunks[0]) | |
| 86 | 87 | h.chunks = append(h.chunks, view.chunks[0]) | |
| 87 | 88 | h.rows += len(view.chunks[0].rows) | |
| 88 | 89 | h.cells += view.Cells() | |
| @@ -95,6 +96,14 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 95 | 96 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | |
| 96 | 97 | h.tail = view.chunks[0].rows | |
| 97 | 98 | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | |
| 99 | + | h.tailIDs = growRowIDs(view.chunks[0].rowIDs, len(h.tail)) | |
| 100 | + | for i := range h.tailIDs { | |
| 101 | + | if h.tailIDs[i] == 0 || h.hasRowID(h.tailIDs[i]) { | |
| 102 | + | h.tailIDs[i] = h.allocateRowID() | |
| 103 | + | } else if h.tailIDs[i] >= h.nextRowID { | |
| 104 | + | h.nextRowID = h.tailIDs[i] + 1 | |
| 105 | + | } | |
| 106 | + | } | |
| 98 | 107 | h.rows += len(h.tail) | |
| 99 | 108 | h.cells += view.Cells() | |
| 100 | 109 | } | |
| @@ -83,6 +83,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 83 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | 83 | return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | ||
| 84 | } | 84 | } | ||
| 85 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | 85 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | ||
| 86 | + | h.assignChunkIDs(view.chunks[0]) | |||
| 86 | h.chunks = append(h.chunks, view.chunks[0]) | 87 | h.chunks = append(h.chunks, view.chunks[0]) | ||
| 87 | h.rows += len(view.chunks[0].rows) | 88 | h.rows += len(view.chunks[0].rows) | ||
| 88 | h.cells += view.Cells() | 89 | h.cells += view.Cells() | ||
| @@ -95,6 +96,14 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 95 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | 96 | h.evictUntil(len(view.chunks[0].rows), view.Cells()) | ||
| 96 | h.tail = view.chunks[0].rows | 97 | h.tail = view.chunks[0].rows | ||
| 97 | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | 98 | h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail)) | ||
| 99 | + | h.tailIDs = growRowIDs(view.chunks[0].rowIDs, len(h.tail)) | |||
| 100 | + | for i := range h.tailIDs { | |||
| 101 | + | if h.tailIDs[i] == 0 || h.hasRowID(h.tailIDs[i]) { | |||
| 102 | + | h.tailIDs[i] = h.allocateRowID() | |||
| 103 | + | } else if h.tailIDs[i] >= h.nextRowID { | |||
| 104 | + | h.nextRowID = h.tailIDs[i] + 1 | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 98 | h.rows += len(h.tail) | 107 | h.rows += len(h.tail) | ||
| 99 | h.cells += view.Cells() | 108 | h.cells += view.Cells() | ||
| 100 | } | 109 | } | ||