feat(vt): bound history by retained cells
- commit
- d75df92e39b2c94e0d24362cae26e6dbb192467e
- author
- brice · · 2026-07-21 18:45 UTC
- parent
- 2b23600
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +216 -19
M pkg/vt/history.go +141 -16
| @@ -1,10 +1,20 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "math" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | |
| 11 | + | // cell budget. The history is not modified when this error is returned. | |
| 12 | + | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |
| 4 | 13 | ||
| 5 | 14 | // HistoryConfig controls the bounded terminal history retained by a Screen. | |
| 6 | 15 | type HistoryConfig struct { | |
| 7 | 16 | MaxRows int | |
| 17 | + | MaxCells int | |
| 8 | 18 | ChunkRows int | |
| 9 | 19 | } | |
| 10 | 20 | ||
| @@ -13,10 +23,12 @@ type HistoryConfig struct { | |||
| 13 | 23 | // appends. | |
| 14 | 24 | type History struct { | |
| 15 | 25 | maxRows int | |
| 26 | + | maxCells int | |
| 16 | 27 | chunkRows int | |
| 17 | 28 | chunks []*HistoryChunk | |
| 18 | 29 | tail [][]renderer.Cell | |
| 19 | 30 | rows int | |
| 31 | + | cells int | |
| 20 | 32 | } | |
| 21 | 33 | ||
| 22 | 34 | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| @@ -30,6 +42,17 @@ type HistoryChunk struct { | |||
| 30 | 42 | type HistoryView struct { | |
| 31 | 43 | chunks []*HistoryChunk | |
| 32 | 44 | rows int | |
| 45 | + | cells int | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // HistorySnapshotView captures sealed history chunks and the mutable tail | |
| 49 | + | // independently. Sealed chunks are shared by identity; Tail is owned by the | |
| 50 | + | // view and can be serialized without rotating the live tail into a chunk. | |
| 51 | + | type HistorySnapshotView struct { | |
| 52 | + | chunks []*HistoryChunk | |
| 53 | + | tail [][]renderer.Cell | |
| 54 | + | rows int | |
| 55 | + | cells int | |
| 33 | 56 | } | |
| 34 | 57 | ||
| 35 | 58 | func NewHistory(config HistoryConfig) *History { | |
| @@ -42,20 +65,37 @@ func NewHistory(config HistoryConfig) *History { | |||
| 42 | 65 | } | |
| 43 | 66 | chunkRows = min(chunkRows, 256) | |
| 44 | 67 | chunkRows = min(chunkRows, config.MaxRows) | |
| 45 | - | return &History{maxRows: config.MaxRows, chunkRows: chunkRows} | |
| 68 | + | maxCells := config.MaxCells | |
| 69 | + | if maxCells <= 0 { | |
| 70 | + | maxCells = defaultHistoryMaxCells(config.MaxRows) | |
| 71 | + | } | |
| 72 | + | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func defaultHistoryMaxCells(maxRows int) int { | |
| 76 | + | if maxRows > math.MaxInt/160 { | |
| 77 | + | return math.MaxInt | |
| 78 | + | } | |
| 79 | + | return maxRows * 160 | |
| 46 | 80 | } | |
| 47 | 81 | ||
| 48 | 82 | // Append records a copy of row. Once a chunk is full it is sealed forever. | |
| 49 | - | func (h *History) Append(row []renderer.Cell) { | |
| 83 | + | // Rows wider than the total cell capacity are rejected without mutation. | |
| 84 | + | func (h *History) Append(row []renderer.Cell) error { | |
| 50 | 85 | if h == nil || h.maxRows == 0 { | |
| 51 | - | return | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | if len(row) > h.maxCells { | |
| 89 | + | return ErrHistoryRowTooWide | |
| 52 | 90 | } | |
| 53 | 91 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 54 | 92 | h.rows++ | |
| 93 | + | h.cells += len(row) | |
| 55 | 94 | if len(h.tail) == h.chunkRows { | |
| 56 | 95 | h.sealTail() | |
| 57 | 96 | } | |
| 58 | 97 | h.evict() | |
| 98 | + | return nil | |
| 59 | 99 | } | |
| 60 | 100 | ||
| 61 | 101 | func (h *History) sealTail() { | |
| @@ -67,12 +107,31 @@ func (h *History) sealTail() { | |||
| 67 | 107 | } | |
| 68 | 108 | ||
| 69 | 109 | func (h *History) evict() { | |
| 70 | - | for h.rows > h.maxRows && len(h.chunks) > 0 { | |
| 71 | - | evicted := h.chunks[0] | |
| 72 | - | h.rows -= len(evicted.rows) | |
| 73 | - | copy(h.chunks, h.chunks[1:]) | |
| 74 | - | h.chunks[len(h.chunks)-1] = nil | |
| 75 | - | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 110 | + | for h.rows > h.maxRows || h.cells > h.maxCells { | |
| 111 | + | if len(h.chunks) > 0 { | |
| 112 | + | chunk := h.chunks[0] | |
| 113 | + | row := chunk.rows[0] | |
| 114 | + | h.rows-- | |
| 115 | + | h.cells -= len(row) | |
| 116 | + | if len(chunk.rows) == 1 { | |
| 117 | + | copy(h.chunks, h.chunks[1:]) | |
| 118 | + | h.chunks[len(h.chunks)-1] = nil | |
| 119 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 120 | + | } else { | |
| 121 | + | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 122 | + | // retained view may still refer to the original immutable chunk. | |
| 123 | + | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]} | |
| 124 | + | } | |
| 125 | + | continue | |
| 126 | + | } | |
| 127 | + | if len(h.tail) == 0 { | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | row := h.tail[0] | |
| 131 | + | h.rows-- | |
| 132 | + | h.cells -= len(row) | |
| 133 | + | h.tail[0] = nil | |
| 134 | + | h.tail = h.tail[1:] | |
| 76 | 135 | } | |
| 77 | 136 | } | |
| 78 | 137 | ||
| @@ -93,13 +152,34 @@ func (h *History) View() HistoryView { | |||
| 93 | 152 | } | |
| 94 | 153 | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 95 | 154 | if len(h.tail) > 0 { | |
| 96 | - | rows := make([][]renderer.Cell, len(h.tail)) | |
| 97 | - | for i, row := range h.tail { | |
| 98 | - | rows[i] = append([]renderer.Cell(nil), row...) | |
| 99 | - | } | |
| 100 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 155 | + | chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)}) | |
| 156 | + | } | |
| 157 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // SnapshotView captures history for persistence without sealing the mutable | |
| 161 | + | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | |
| 162 | + | func (h *History) SnapshotView() HistorySnapshotView { | |
| 163 | + | if h == nil || h.rows == 0 { | |
| 164 | + | return HistorySnapshotView{} | |
| 165 | + | } | |
| 166 | + | return HistorySnapshotView{ | |
| 167 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 168 | + | tail: cloneHistoryRows(h.tail), | |
| 169 | + | rows: h.rows, | |
| 170 | + | cells: h.cells, | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell { | |
| 175 | + | if len(rows) == 0 { | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | copyRows := make([][]renderer.Cell, len(rows)) | |
| 179 | + | for i, row := range rows { | |
| 180 | + | copyRows[i] = append([]renderer.Cell(nil), row...) | |
| 101 | 181 | } | |
| 102 | - | return HistoryView{chunks: chunks, rows: h.rows} | |
| 182 | + | return copyRows | |
| 103 | 183 | } | |
| 104 | 184 | ||
| 105 | 185 | // Len returns the currently retained history row count. | |
| @@ -110,6 +190,14 @@ func (h *History) Len() int { | |||
| 110 | 190 | return h.rows | |
| 111 | 191 | } | |
| 112 | 192 | ||
| 193 | + | // Cells returns the currently retained history cell count. | |
| 194 | + | func (h *History) Cells() int { | |
| 195 | + | if h == nil { | |
| 196 | + | return 0 | |
| 197 | + | } | |
| 198 | + | return h.cells | |
| 199 | + | } | |
| 200 | + | ||
| 113 | 201 | // Cap returns the configured bounded row capacity. | |
| 114 | 202 | func (h *History) Cap() int { | |
| 115 | 203 | if h == nil { | |
| @@ -118,7 +206,16 @@ func (h *History) Cap() int { | |||
| 118 | 206 | return h.maxRows | |
| 119 | 207 | } | |
| 120 | 208 | ||
| 209 | + | // CellCap returns the configured bounded cell capacity. | |
| 210 | + | func (h *History) CellCap() int { | |
| 211 | + | if h == nil { | |
| 212 | + | return 0 | |
| 213 | + | } | |
| 214 | + | return h.maxCells | |
| 215 | + | } | |
| 216 | + | ||
| 121 | 217 | func (v HistoryView) Len() int { return v.rows } | |
| 218 | + | func (v HistoryView) Cells() int { return v.cells } | |
| 122 | 219 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |
| 123 | 220 | ||
| 124 | 221 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |
| @@ -161,3 +258,31 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 161 | 258 | } | |
| 162 | 259 | return nil | |
| 163 | 260 | } | |
| 261 | + | ||
| 262 | + | func (v HistorySnapshotView) Len() int { return v.rows } | |
| 263 | + | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 264 | + | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |
| 265 | + | ||
| 266 | + | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | |
| 267 | + | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |
| 268 | + | if i < 0 || i >= len(v.chunks) { | |
| 269 | + | return nil | |
| 270 | + | } | |
| 271 | + | return v.chunks[i] | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // Tail returns an immutable view of the copied mutable tail. | |
| 275 | + | func (v HistorySnapshotView) Tail() HistoryView { | |
| 276 | + | if len(v.tail) == 0 { | |
| 277 | + | return HistoryView{} | |
| 278 | + | } | |
| 279 | + | return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)} | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func historyRowsCells(rows [][]renderer.Cell) int { | |
| 283 | + | cells := 0 | |
| 284 | + | for _, row := range rows { | |
| 285 | + | cells += len(row) | |
| 286 | + | } | |
| 287 | + | return cells | |
| 288 | + | } | |
| @@ -1,10 +1,20 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | 3 | + | import ( |
| 4 | + | "errors" | |||
| 5 | + | "math" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | |||
| 11 | + | // cell budget. The history is not modified when this error is returned. | |||
| 12 | + | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |||
| 4 | 13 | ||||
| 5 | // HistoryConfig controls the bounded terminal history retained by a Screen. | 14 | // HistoryConfig controls the bounded terminal history retained by a Screen. | ||
| 6 | type HistoryConfig struct { | 15 | type HistoryConfig struct { | ||
| 7 | MaxRows int | 16 | MaxRows int | ||
| 17 | + | MaxCells int | |||
| 8 | ChunkRows int | 18 | ChunkRows int | ||
| 9 | } | 19 | } | ||
| 10 | 20 | ||||
| @@ -13,10 +23,12 @@ type HistoryConfig struct { | |||||
| 13 | // appends. | 23 | // appends. | ||
| 14 | type History struct { | 24 | type History struct { | ||
| 15 | maxRows int | 25 | maxRows int | ||
| 26 | + | maxCells int | |||
| 16 | chunkRows int | 27 | chunkRows int | ||
| 17 | chunks []*HistoryChunk | 28 | chunks []*HistoryChunk | ||
| 18 | tail [][]renderer.Cell | 29 | tail [][]renderer.Cell | ||
| 19 | rows int | 30 | rows int | ||
| 31 | + | cells int | |||
| 20 | } | 32 | } | ||
| 21 | 33 | ||||
| 22 | // HistoryChunk is an immutable group of history rows. Its identity is stable | 34 | // HistoryChunk is an immutable group of history rows. Its identity is stable | ||
| @@ -30,6 +42,17 @@ type HistoryChunk struct { | |||||
| 30 | type HistoryView struct { | 42 | type HistoryView struct { | ||
| 31 | chunks []*HistoryChunk | 43 | chunks []*HistoryChunk | ||
| 32 | rows int | 44 | rows int | ||
| 45 | + | cells int | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // HistorySnapshotView captures sealed history chunks and the mutable tail | |||
| 49 | + | // independently. Sealed chunks are shared by identity; Tail is owned by the | |||
| 50 | + | // view and can be serialized without rotating the live tail into a chunk. | |||
| 51 | + | type HistorySnapshotView struct { | |||
| 52 | + | chunks []*HistoryChunk | |||
| 53 | + | tail [][]renderer.Cell | |||
| 54 | + | rows int | |||
| 55 | + | cells int | |||
| 33 | } | 56 | } | ||
| 34 | 57 | ||||
| 35 | func NewHistory(config HistoryConfig) *History { | 58 | func NewHistory(config HistoryConfig) *History { | ||
| @@ -42,20 +65,37 @@ func NewHistory(config HistoryConfig) *History { | |||||
| 42 | } | 65 | } | ||
| 43 | chunkRows = min(chunkRows, 256) | 66 | chunkRows = min(chunkRows, 256) | ||
| 44 | chunkRows = min(chunkRows, config.MaxRows) | 67 | chunkRows = min(chunkRows, config.MaxRows) | ||
| 45 | - | return &History{maxRows: config.MaxRows, chunkRows: chunkRows} | 68 | + | maxCells := config.MaxCells |
| 69 | + | if maxCells <= 0 { | |||
| 70 | + | maxCells = defaultHistoryMaxCells(config.MaxRows) | |||
| 71 | + | } | |||
| 72 | + | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func defaultHistoryMaxCells(maxRows int) int { | |||
| 76 | + | if maxRows > math.MaxInt/160 { | |||
| 77 | + | return math.MaxInt | |||
| 78 | + | } | |||
| 79 | + | return maxRows * 160 | |||
| 46 | } | 80 | } | ||
| 47 | 81 | ||||
| 48 | // Append records a copy of row. Once a chunk is full it is sealed forever. | 82 | // Append records a copy of row. Once a chunk is full it is sealed forever. | ||
| 49 | - | func (h *History) Append(row []renderer.Cell) { | 83 | + | // Rows wider than the total cell capacity are rejected without mutation. |
| 84 | + | func (h *History) Append(row []renderer.Cell) error { | |||
| 50 | if h == nil || h.maxRows == 0 { | 85 | if h == nil || h.maxRows == 0 { | ||
| 51 | - | return | 86 | + | return nil |
| 87 | + | } | |||
| 88 | + | if len(row) > h.maxCells { | |||
| 89 | + | return ErrHistoryRowTooWide | |||
| 52 | } | 90 | } | ||
| 53 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | 91 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | ||
| 54 | h.rows++ | 92 | h.rows++ | ||
| 93 | + | h.cells += len(row) | |||
| 55 | if len(h.tail) == h.chunkRows { | 94 | if len(h.tail) == h.chunkRows { | ||
| 56 | h.sealTail() | 95 | h.sealTail() | ||
| 57 | } | 96 | } | ||
| 58 | h.evict() | 97 | h.evict() | ||
| 98 | + | return nil | |||
| 59 | } | 99 | } | ||
| 60 | 100 | ||||
| 61 | func (h *History) sealTail() { | 101 | func (h *History) sealTail() { | ||
| @@ -67,12 +107,31 @@ func (h *History) sealTail() { | |||||
| 67 | } | 107 | } | ||
| 68 | 108 | ||||
| 69 | func (h *History) evict() { | 109 | func (h *History) evict() { | ||
| 70 | - | for h.rows > h.maxRows && len(h.chunks) > 0 { | 110 | + | for h.rows > h.maxRows || h.cells > h.maxCells { |
| 71 | - | evicted := h.chunks[0] | 111 | + | if len(h.chunks) > 0 { |
| 72 | - | h.rows -= len(evicted.rows) | 112 | + | chunk := h.chunks[0] |
| 73 | - | copy(h.chunks, h.chunks[1:]) | 113 | + | row := chunk.rows[0] |
| 74 | - | h.chunks[len(h.chunks)-1] = nil | 114 | + | h.rows-- |
| 75 | - | h.chunks = h.chunks[:len(h.chunks)-1] | 115 | + | h.cells -= len(row) |
| 116 | + | if len(chunk.rows) == 1 { | |||
| 117 | + | copy(h.chunks, h.chunks[1:]) | |||
| 118 | + | h.chunks[len(h.chunks)-1] = nil | |||
| 119 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |||
| 120 | + | } else { | |||
| 121 | + | // Preserve cell storage while replacing only the chunk wrapper: a | |||
| 122 | + | // retained view may still refer to the original immutable chunk. | |||
| 123 | + | h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]} | |||
| 124 | + | } | |||
| 125 | + | continue | |||
| 126 | + | } | |||
| 127 | + | if len(h.tail) == 0 { | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | row := h.tail[0] | |||
| 131 | + | h.rows-- | |||
| 132 | + | h.cells -= len(row) | |||
| 133 | + | h.tail[0] = nil | |||
| 134 | + | h.tail = h.tail[1:] | |||
| 76 | } | 135 | } | ||
| 77 | } | 136 | } | ||
| 78 | 137 | ||||
| @@ -93,13 +152,34 @@ func (h *History) View() HistoryView { | |||||
| 93 | } | 152 | } | ||
| 94 | chunks := append([]*HistoryChunk(nil), h.chunks...) | 153 | chunks := append([]*HistoryChunk(nil), h.chunks...) | ||
| 95 | if len(h.tail) > 0 { | 154 | if len(h.tail) > 0 { | ||
| 96 | - | rows := make([][]renderer.Cell, len(h.tail)) | 155 | + | chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)}) |
| 97 | - | for i, row := range h.tail { | 156 | + | } |
| 98 | - | rows[i] = append([]renderer.Cell(nil), row...) | 157 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells} |
| 99 | - | } | 158 | + | } |
| 100 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | 159 | + | |
| 160 | + | // SnapshotView captures history for persistence without sealing the mutable | |||
| 161 | + | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | |||
| 162 | + | func (h *History) SnapshotView() HistorySnapshotView { | |||
| 163 | + | if h == nil || h.rows == 0 { | |||
| 164 | + | return HistorySnapshotView{} | |||
| 165 | + | } | |||
| 166 | + | return HistorySnapshotView{ | |||
| 167 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), | |||
| 168 | + | tail: cloneHistoryRows(h.tail), | |||
| 169 | + | rows: h.rows, | |||
| 170 | + | cells: h.cells, | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell { | |||
| 175 | + | if len(rows) == 0 { | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | copyRows := make([][]renderer.Cell, len(rows)) | |||
| 179 | + | for i, row := range rows { | |||
| 180 | + | copyRows[i] = append([]renderer.Cell(nil), row...) | |||
| 101 | } | 181 | } | ||
| 102 | - | return HistoryView{chunks: chunks, rows: h.rows} | 182 | + | return copyRows |
| 103 | } | 183 | } | ||
| 104 | 184 | ||||
| 105 | // Len returns the currently retained history row count. | 185 | // Len returns the currently retained history row count. | ||
| @@ -110,6 +190,14 @@ func (h *History) Len() int { | |||||
| 110 | return h.rows | 190 | return h.rows | ||
| 111 | } | 191 | } | ||
| 112 | 192 | ||||
| 193 | + | // Cells returns the currently retained history cell count. | |||
| 194 | + | func (h *History) Cells() int { | |||
| 195 | + | if h == nil { | |||
| 196 | + | return 0 | |||
| 197 | + | } | |||
| 198 | + | return h.cells | |||
| 199 | + | } | |||
| 200 | + | ||||
| 113 | // Cap returns the configured bounded row capacity. | 201 | // Cap returns the configured bounded row capacity. | ||
| 114 | func (h *History) Cap() int { | 202 | func (h *History) Cap() int { | ||
| 115 | if h == nil { | 203 | if h == nil { | ||
| @@ -118,7 +206,16 @@ func (h *History) Cap() int { | |||||
| 118 | return h.maxRows | 206 | return h.maxRows | ||
| 119 | } | 207 | } | ||
| 120 | 208 | ||||
| 209 | + | // CellCap returns the configured bounded cell capacity. | |||
| 210 | + | func (h *History) CellCap() int { | |||
| 211 | + | if h == nil { | |||
| 212 | + | return 0 | |||
| 213 | + | } | |||
| 214 | + | return h.maxCells | |||
| 215 | + | } | |||
| 216 | + | ||||
| 121 | func (v HistoryView) Len() int { return v.rows } | 217 | func (v HistoryView) Len() int { return v.rows } | ||
| 218 | + | func (v HistoryView) Cells() int { return v.cells } | |||
| 122 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | 219 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | ||
| 123 | 220 | ||||
| 124 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | 221 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | ||
| @@ -161,3 +258,31 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||||
| 161 | } | 258 | } | ||
| 162 | return nil | 259 | return nil | ||
| 163 | } | 260 | } | ||
| 261 | + | ||||
| 262 | + | func (v HistorySnapshotView) Len() int { return v.rows } | |||
| 263 | + | func (v HistorySnapshotView) Cells() int { return v.cells } | |||
| 264 | + | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |||
| 265 | + | ||||
| 266 | + | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | |||
| 267 | + | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||
| 268 | + | if i < 0 || i >= len(v.chunks) { | |||
| 269 | + | return nil | |||
| 270 | + | } | |||
| 271 | + | return v.chunks[i] | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | // Tail returns an immutable view of the copied mutable tail. | |||
| 275 | + | func (v HistorySnapshotView) Tail() HistoryView { | |||
| 276 | + | if len(v.tail) == 0 { | |||
| 277 | + | return HistoryView{} | |||
| 278 | + | } | |||
| 279 | + | return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)} | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func historyRowsCells(rows [][]renderer.Cell) int { | |||
| 283 | + | cells := 0 | |||
| 284 | + | for _, row := range rows { | |||
| 285 | + | cells += len(row) | |||
| 286 | + | } | |||
| 287 | + | return cells | |||
| 288 | + | } | |||
M pkg/vt/history_test.go +72 -2
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 5 | + | "math" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -22,11 +24,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||
| 22 | 24 | want: []string{"cccc", "dddd", "eeee", "ffff"}, | |
| 23 | 25 | }, | |
| 24 | 26 | { | |
| 25 | - | name: "nonmultiple capacity evicts a whole oldest immutable chunk", | |
| 27 | + | name: "nonmultiple capacity evicts exactly the oldest row", | |
| 26 | 28 | maxRows: 5, | |
| 27 | 29 | chunkRows: 2, | |
| 28 | 30 | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | |
| 29 | - | want: []string{"2222", "3333", "4444", "5555"}, | |
| 31 | + | want: []string{"1111", "2222", "3333", "4444", "5555"}, | |
| 30 | 32 | }, | |
| 31 | 33 | { | |
| 32 | 34 | name: "rows preserve append order across chunk boundaries", | |
| @@ -192,6 +194,74 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||
| 192 | 194 | } | |
| 193 | 195 | } | |
| 194 | 196 | ||
| 197 | + | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | |
| 198 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |
| 199 | + | for _, text := range []string{"aa", "bbb", "c", "dd"} { | |
| 200 | + | if err := history.Append(historyRow(text)); err != nil { | |
| 201 | + | t.Fatalf("append %q: %v", text, err) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | view := history.View() | |
| 206 | + | if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) { | |
| 207 | + | t.Fatalf("retained rows = %#v, want %#v", got, want) | |
| 208 | + | } | |
| 209 | + | if got, want := history.Len(), 2; got != want { | |
| 210 | + | t.Fatalf("retained rows = %d, want %d", got, want) | |
| 211 | + | } | |
| 212 | + | if got, want := history.Cells(), 3; got != want { | |
| 213 | + | t.Fatalf("retained cells = %d, want %d", got, want) | |
| 214 | + | } | |
| 215 | + | if got, want := view.Cells(), 3; got != want { | |
| 216 | + | t.Fatalf("view cells = %d, want %d", got, want) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |
| 221 | + | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | |
| 222 | + | if err := history.Append(historyRow("ok")); err != nil { | |
| 223 | + | t.Fatalf("append retained row: %v", err) | |
| 224 | + | } | |
| 225 | + | before := history.View() | |
| 226 | + | ||
| 227 | + | err := history.Append(historyRow("wide")) | |
| 228 | + | if !errors.Is(err, ErrHistoryRowTooWide) { | |
| 229 | + | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | |
| 230 | + | } | |
| 231 | + | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | |
| 232 | + | t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) | |
| 233 | + | } | |
| 234 | + | if got, want := history.Cells(), 2; got != want { | |
| 235 | + | t.Fatalf("retained cells after rejected append = %d, want %d", got, want) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { | |
| 240 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3}) | |
| 241 | + | events := 0 | |
| 242 | + | screen.OnLineEvicted = func([]renderer.Cell) { events++ } | |
| 243 | + | copy(screen.Frame.Row(0), historyRow("AAAA")) | |
| 244 | + | copy(screen.Frame.Row(1), historyRow("BBBB")) | |
| 245 | + | screen.scrollUpRegion(0, 1, 1) | |
| 246 | + | ||
| 247 | + | if got := screen.History().Len(); got != 0 { | |
| 248 | + | t.Fatalf("oversized scrollback rows = %d, want 0", got) | |
| 249 | + | } | |
| 250 | + | if got := events; got != 1 { | |
| 251 | + | t.Fatalf("scroll callbacks = %d, want 1", got) | |
| 252 | + | } | |
| 253 | + | if got := lineText(screen, 0); got != "BBBB" { | |
| 254 | + | t.Fatalf("screen did not scroll after history drop: row 0 = %q", got) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | |
| 259 | + | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |
| 260 | + | if got, want := history.CellCap(), math.MaxInt; got != want { | |
| 261 | + | t.Fatalf("default cell cap = %d, want %d", got, want) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 195 | 265 | func historyRow(text string) []renderer.Cell { | |
| 196 | 266 | row := make([]renderer.Cell, len(text)) | |
| 197 | 267 | for i, r := range text { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 5 | + | "math" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -22,11 +24,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||||
| 22 | want: []string{"cccc", "dddd", "eeee", "ffff"}, | 24 | want: []string{"cccc", "dddd", "eeee", "ffff"}, | ||
| 23 | }, | 25 | }, | ||
| 24 | { | 26 | { | ||
| 25 | - | name: "nonmultiple capacity evicts a whole oldest immutable chunk", | 27 | + | name: "nonmultiple capacity evicts exactly the oldest row", |
| 26 | maxRows: 5, | 28 | maxRows: 5, | ||
| 27 | chunkRows: 2, | 29 | chunkRows: 2, | ||
| 28 | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | 30 | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | ||
| 29 | - | want: []string{"2222", "3333", "4444", "5555"}, | 31 | + | want: []string{"1111", "2222", "3333", "4444", "5555"}, |
| 30 | }, | 32 | }, | ||
| 31 | { | 33 | { | ||
| 32 | name: "rows preserve append order across chunk boundaries", | 34 | name: "rows preserve append order across chunk boundaries", | ||
| @@ -192,6 +194,74 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||||
| 192 | } | 194 | } | ||
| 193 | } | 195 | } | ||
| 194 | 196 | ||||
| 197 | + | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | |||
| 198 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |||
| 199 | + | for _, text := range []string{"aa", "bbb", "c", "dd"} { | |||
| 200 | + | if err := history.Append(historyRow(text)); err != nil { | |||
| 201 | + | t.Fatalf("append %q: %v", text, err) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | view := history.View() | |||
| 206 | + | if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) { | |||
| 207 | + | t.Fatalf("retained rows = %#v, want %#v", got, want) | |||
| 208 | + | } | |||
| 209 | + | if got, want := history.Len(), 2; got != want { | |||
| 210 | + | t.Fatalf("retained rows = %d, want %d", got, want) | |||
| 211 | + | } | |||
| 212 | + | if got, want := history.Cells(), 3; got != want { | |||
| 213 | + | t.Fatalf("retained cells = %d, want %d", got, want) | |||
| 214 | + | } | |||
| 215 | + | if got, want := view.Cells(), 3; got != want { | |||
| 216 | + | t.Fatalf("view cells = %d, want %d", got, want) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |||
| 221 | + | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | |||
| 222 | + | if err := history.Append(historyRow("ok")); err != nil { | |||
| 223 | + | t.Fatalf("append retained row: %v", err) | |||
| 224 | + | } | |||
| 225 | + | before := history.View() | |||
| 226 | + | ||||
| 227 | + | err := history.Append(historyRow("wide")) | |||
| 228 | + | if !errors.Is(err, ErrHistoryRowTooWide) { | |||
| 229 | + | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | |||
| 230 | + | } | |||
| 231 | + | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | |||
| 232 | + | t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) | |||
| 233 | + | } | |||
| 234 | + | if got, want := history.Cells(), 2; got != want { | |||
| 235 | + | t.Fatalf("retained cells after rejected append = %d, want %d", got, want) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { | |||
| 240 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3}) | |||
| 241 | + | events := 0 | |||
| 242 | + | screen.OnLineEvicted = func([]renderer.Cell) { events++ } | |||
| 243 | + | copy(screen.Frame.Row(0), historyRow("AAAA")) | |||
| 244 | + | copy(screen.Frame.Row(1), historyRow("BBBB")) | |||
| 245 | + | screen.scrollUpRegion(0, 1, 1) | |||
| 246 | + | ||||
| 247 | + | if got := screen.History().Len(); got != 0 { | |||
| 248 | + | t.Fatalf("oversized scrollback rows = %d, want 0", got) | |||
| 249 | + | } | |||
| 250 | + | if got := events; got != 1 { | |||
| 251 | + | t.Fatalf("scroll callbacks = %d, want 1", got) | |||
| 252 | + | } | |||
| 253 | + | if got := lineText(screen, 0); got != "BBBB" { | |||
| 254 | + | t.Fatalf("screen did not scroll after history drop: row 0 = %q", got) | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | |||
| 259 | + | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |||
| 260 | + | if got, want := history.CellCap(), math.MaxInt; got != want { | |||
| 261 | + | t.Fatalf("default cell cap = %d, want %d", got, want) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 195 | func historyRow(text string) []renderer.Cell { | 265 | func historyRow(text string) []renderer.Cell { | ||
| 196 | row := make([]renderer.Cell, len(text)) | 266 | row := make([]renderer.Cell, len(text)) | ||
| 197 | for i, r := range text { | 267 | for i, r := range text { | ||
M pkg/vt/screen_scroll.go +3 -1
| @@ -88,7 +88,9 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 88 | 88 | ||
| 89 | 89 | func (s *Screen) recordEvicted(row []renderer.Cell) { | |
| 90 | 90 | if s.history != nil { | |
| 91 | - | s.history.Append(row) | |
| 91 | + | // Oversized rows cannot fit the configured cell budget. Scrolling must | |
| 92 | + | // still complete (and notify observers), so history drops that row. | |
| 93 | + | _ = s.history.Append(row) | |
| 92 | 94 | } | |
| 93 | 95 | if s.OnLineEvicted != nil { | |
| 94 | 96 | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
| @@ -88,7 +88,9 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 88 | 88 | ||||
| 89 | func (s *Screen) recordEvicted(row []renderer.Cell) { | 89 | func (s *Screen) recordEvicted(row []renderer.Cell) { | ||
| 90 | if s.history != nil { | 90 | if s.history != nil { | ||
| 91 | - | s.history.Append(row) | 91 | + | // Oversized rows cannot fit the configured cell budget. Scrolling must |
| 92 | + | // still complete (and notify observers), so history drops that row. | |||
| 93 | + | _ = s.history.Append(row) | |||
| 92 | } | 94 | } | ||
| 93 | if s.OnLineEvicted != nil { | 95 | if s.OnLineEvicted != nil { | ||
| 94 | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | 96 | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | ||