perf(vt): streamline viewport reflow
- commit
- b93fa1a683dd8386172ff6e97be9a228297a5d37
- author
- brice · · 2026-07-15 16:00 UTC
- parent
- f6aceff
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +238 -159
M pkg/vt/buffer.go +182 -122
| @@ -117,18 +117,28 @@ func (b *buffer) hasSoft() bool { | |||
| 117 | 117 | ||
| 118 | 118 | // resizeFixed keeps hard physical lines independent. It is the common shell | |
| 119 | 119 | // path and avoids constructing logical-line scratch state when nothing wraps. | |
| 120 | - | func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]renderer.Cell { | |
| 120 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |
| 121 | 121 | anchor := 0 | |
| 122 | - | if len(cursors) > 0 && cursors[0] != nil { | |
| 123 | - | anchor = cursors[0].row | |
| 122 | + | if active != nil { | |
| 123 | + | anchor = active.row | |
| 124 | 124 | } | |
| 125 | 125 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |
| 126 | 126 | evicted := make([][]renderer.Cell, 0, shift) | |
| 127 | - | for y := 0; y < shift; y++ { | |
| 127 | + | for y := range shift { | |
| 128 | 128 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |
| 129 | 129 | } | |
| 130 | - | next := newBuffer(width, height) | |
| 131 | - | for y := 0; y < height; y++ { | |
| 130 | + | // Fixed-line resizes can retain the boundary backing store. In particular, | |
| 131 | + | // keep its capacity through a short viewport so the common shrink/grow | |
| 132 | + | // sequence does not add metadata allocation to every resize epoch. | |
| 133 | + | boundaries := b.boundaries | |
| 134 | + | if cap(boundaries) < height { | |
| 135 | + | boundaries = make([]lineBoundary, height) | |
| 136 | + | } else { | |
| 137 | + | boundaries = boundaries[:height] | |
| 138 | + | } | |
| 139 | + | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries} | |
| 140 | + | copied := 0 | |
| 141 | + | for y := range height { | |
| 132 | 142 | sy := y + shift | |
| 133 | 143 | if sy >= b.frame.Height { | |
| 134 | 144 | break | |
| @@ -137,156 +147,206 @@ func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]re | |||
| 137 | 147 | next.boundaries[y] = b.boundaries[sy] | |
| 138 | 148 | next.boundaries[y].end = min(next.boundaries[y].end, width) | |
| 139 | 149 | repairFrameRow(next.frame, y) | |
| 150 | + | copied++ | |
| 140 | 151 | } | |
| 141 | - | for _, cur := range cursors { | |
| 152 | + | clear(next.boundaries[copied:]) | |
| 153 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |
| 142 | 154 | if cur != nil { | |
| 143 | 155 | cur.row = clamp(cur.row-shift, 0, height-1) | |
| 144 | 156 | cur.col = clamp(cur.col, 0, width) | |
| 145 | 157 | } | |
| 146 | 158 | } | |
| 147 | - | *b = *next | |
| 159 | + | *b = next | |
| 148 | 160 | return evicted | |
| 149 | 161 | } | |
| 150 | 162 | ||
| 151 | - | // resize lays out only the current grid. It returns the top rows discarded by | |
| 152 | - | // height reduction and maps every supplied cursor in the same layout pass. | |
| 153 | - | func (b *buffer) resize(width, height int, cursors ...*bufferCursor) [][]renderer.Cell { | |
| 154 | - | b.hydrate() | |
| 163 | + | type reflowPoint struct { | |
| 164 | + | line, offset int | |
| 165 | + | row, col int | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // cursorReflowPoints maps source cursor positions to offsets in their logical | |
| 169 | + | // lines. There are exactly two callers (active and DECSC), so this stays flat | |
| 170 | + | // and stack allocated rather than building a row/column lookup map. | |
| 171 | + | func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint { | |
| 172 | + | points := [2]reflowPoint{{line: -1}, {line: -1}} | |
| 173 | + | cursors := [2]*bufferCursor{active, saved} | |
| 174 | + | for start := 0; start < b.frame.Height; { | |
| 175 | + | end := start | |
| 176 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |
| 177 | + | end++ | |
| 178 | + | } | |
| 179 | + | for i, cur := range cursors { | |
| 180 | + | if cur == nil || cur.row < start || cur.row > end { | |
| 181 | + | continue | |
| 182 | + | } | |
| 183 | + | offset := 0 | |
| 184 | + | for y := start; y < cur.row; y++ { | |
| 185 | + | offset += b.boundaries[y].end | |
| 186 | + | } | |
| 187 | + | points[i] = reflowPoint{ | |
| 188 | + | line: start, | |
| 189 | + | offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end), | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | start = end + 1 | |
| 193 | + | } | |
| 194 | + | return points | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // resize lays out only the current grid. It does two direct, bounded passes: | |
| 198 | + | // the first maps both cursors and counts rows; the second writes only the | |
| 199 | + | // retained viewport (and the rows genuinely evicted to history). It never | |
| 200 | + | // materializes logical lines, per-cell position maps, or temporary output rows. | |
| 201 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |
| 155 | 202 | if !b.hasSoft() { | |
| 156 | - | return b.resizeFixed(width, height, cursors...) | |
| 203 | + | return b.resizeFixed(width, height, active, saved) | |
| 157 | 204 | } | |
| 158 | - | for _, cur := range cursors { | |
| 205 | + | // Only reflow needs meaningful extents; hard physical rows are copied as | |
| 206 | + | // cells, so scanning blank rows on the shell fast path is wasted work. | |
| 207 | + | b.hydrate() | |
| 208 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |
| 159 | 209 | if cur != nil { | |
| 160 | 210 | b.content(cur.row, cur.col) | |
| 161 | 211 | } | |
| 162 | 212 | } | |
| 163 | - | oldW := b.frame.Width | |
| 164 | - | type logical struct { | |
| 165 | - | cells []renderer.Cell | |
| 166 | - | points map[int]int // old row/column key -> cell offset | |
| 167 | - | reflow bool | |
| 213 | + | ||
| 214 | + | points := b.cursorReflowPoints(active, saved) | |
| 215 | + | rows := b.layoutReflow(width, &points, nil, 0, nil) | |
| 216 | + | anchor := 0 | |
| 217 | + | if active != nil { | |
| 218 | + | anchor = points[0].row | |
| 168 | 219 | } | |
| 169 | - | var lines []logical | |
| 170 | - | for y := 0; y < b.frame.Height; { | |
| 171 | - | line := logical{points: make(map[int]int)} | |
| 172 | - | for { | |
| 173 | - | end := b.boundaries[y].end | |
| 174 | - | for x := 0; x <= oldW; x++ { | |
| 175 | - | line.points[y*(oldW+1)+x] = len(line.cells) + min(x, end) | |
| 176 | - | } | |
| 177 | - | line.cells = append(line.cells, b.frame.Row(y)[:end]...) | |
| 178 | - | soft := b.boundaries[y].soft && y+1 < b.frame.Height | |
| 179 | - | line.reflow = line.reflow || soft | |
| 180 | - | y++ | |
| 181 | - | if !soft { | |
| 182 | - | break | |
| 183 | - | } | |
| 220 | + | shift := clamp(anchor-(height-1), 0, max(rows-height, 0)) | |
| 221 | + | ||
| 222 | + | next := newBuffer(width, height) | |
| 223 | + | var evicted [][]renderer.Cell | |
| 224 | + | var evictedCells []renderer.Cell | |
| 225 | + | if shift > 0 { | |
| 226 | + | // History needs owned rows. One flat backing store replaces a temporary | |
| 227 | + | // allocation per evicted output row. | |
| 228 | + | evicted = make([][]renderer.Cell, shift) | |
| 229 | + | evictedCells = make([]renderer.Cell, shift*width) | |
| 230 | + | for y := range evicted { | |
| 231 | + | evicted[y] = evictedCells[y*width : (y+1)*width] | |
| 184 | 232 | } | |
| 185 | - | lines = append(lines, line) | |
| 186 | 233 | } | |
| 187 | - | ||
| 188 | - | type point struct{ row, col int } | |
| 189 | - | cursorOffsets := make([]struct{ line, offset int }, len(cursors)) | |
| 190 | - | for ci, cur := range cursors { | |
| 191 | - | if cur == nil { | |
| 192 | - | continue | |
| 234 | + | b.layoutReflow(width, &points, next, shift, evictedCells) | |
| 235 | + | for i, cur := range [2]*bufferCursor{active, saved} { | |
| 236 | + | if cur != nil { | |
| 237 | + | cur.row = clamp(points[i].row-shift, 0, height-1) | |
| 238 | + | cur.col = clamp(points[i].col, 0, width) | |
| 193 | 239 | } | |
| 194 | - | key := clamp(cur.row, 0, max(b.frame.Height-1, 0))*(oldW+1) + clamp(cur.col, 0, oldW) | |
| 195 | - | for li := range lines { | |
| 196 | - | if offset, ok := lines[li].points[key]; ok { | |
| 197 | - | cursorOffsets[ci] = struct{ line, offset int }{li, offset} | |
| 198 | - | break | |
| 240 | + | } | |
| 241 | + | *b = *next | |
| 242 | + | return evicted | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| 246 | + | // [shift, shift+dst.height). Rows before shift are written straight into the | |
| 247 | + | // contiguous eviction backing store. The return is the logical output height. | |
| 248 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int { | |
| 249 | + | row, col := 0, 0 | |
| 250 | + | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 251 | + | if outputRow < shift { | |
| 252 | + | out := evicted[outputRow*width : (outputRow+1)*width] | |
| 253 | + | for i := range out { | |
| 254 | + | out[i] = renderer.BlankCell() | |
| 199 | 255 | } | |
| 256 | + | return out | |
| 257 | + | } | |
| 258 | + | if dst != nil && outputRow < shift+dst.frame.Height { | |
| 259 | + | return dst.frame.Row(outputRow - shift) | |
| 200 | 260 | } | |
| 261 | + | return nil | |
| 201 | 262 | } | |
| 202 | - | ||
| 203 | - | var rows [][]renderer.Cell | |
| 204 | - | var bounds []lineBoundary | |
| 205 | - | positions := make([][]point, len(lines)) | |
| 206 | - | for li, line := range lines { | |
| 207 | - | positions[li] = make([]point, len(line.cells)+1) | |
| 208 | - | row := make([]renderer.Cell, width) | |
| 209 | - | for x := range row { | |
| 210 | - | row[x] = renderer.BlankCell() | |
| 263 | + | var output []renderer.Cell | |
| 264 | + | if dst != nil && (shift > 0 || dst.frame.Height > 0) { | |
| 265 | + | output = blankEvicted(0) | |
| 266 | + | } | |
| 267 | + | finishRow := func(soft bool) { | |
| 268 | + | if dst != nil && row >= shift && row < shift+dst.frame.Height { | |
| 269 | + | dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft} | |
| 211 | 270 | } | |
| 212 | - | x := 0 | |
| 213 | - | for i := 0; i < len(line.cells); { | |
| 214 | - | if line.cells[i].Continuation { // corrupt input is repaired below. | |
| 215 | - | i++ | |
| 216 | - | continue | |
| 271 | + | row++ | |
| 272 | + | col = 0 | |
| 273 | + | if dst != nil { | |
| 274 | + | output = blankEvicted(row) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | setPoint := func(line, offset, pointRow, pointCol int) { | |
| 278 | + | for i := range points { | |
| 279 | + | if points[i].line == line && points[i].offset == offset { | |
| 280 | + | points[i].row, points[i].col = pointRow, pointCol | |
| 217 | 281 | } | |
| 218 | - | w := renderer.RuneWidth(line.cells[i].Rune) | |
| 219 | - | wide := w == 2 && i+1 < len(line.cells) && line.cells[i+1].Continuation | |
| 220 | - | if !wide || width < 2 { | |
| 221 | - | w = 1 | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | setRemainingPoints := func(line, offset, pointRow, pointCol int) { | |
| 285 | + | for i := range points { | |
| 286 | + | if points[i].line == line && points[i].offset >= offset { | |
| 287 | + | points[i].row, points[i].col = pointRow, pointCol | |
| 222 | 288 | } | |
| 223 | - | if x+w > width && x > 0 { | |
| 224 | - | if !line.reflow { | |
| 225 | - | for j := i; j <= len(line.cells); j++ { | |
| 226 | - | positions[li][j] = point{len(rows), x} | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | for start := 0; start < b.frame.Height; { | |
| 293 | + | end := start | |
| 294 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |
| 295 | + | end++ | |
| 296 | + | } | |
| 297 | + | reflow := end > start | |
| 298 | + | offset := 0 | |
| 299 | + | truncated := false | |
| 300 | + | for y := start; y <= end && !truncated; y++ { | |
| 301 | + | cells := b.frame.Row(y) | |
| 302 | + | limit := b.boundaries[y].end | |
| 303 | + | for x := 0; x < limit; { | |
| 304 | + | cell := cells[x] | |
| 305 | + | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | |
| 306 | + | setPoint(start, offset, row, col) | |
| 307 | + | x++ | |
| 308 | + | offset++ | |
| 309 | + | continue | |
| 310 | + | } | |
| 311 | + | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation | |
| 312 | + | w := 1 | |
| 313 | + | if wide && width >= 2 { | |
| 314 | + | w = 2 | |
| 315 | + | } | |
| 316 | + | if col+w > width && col > 0 { | |
| 317 | + | if !reflow { | |
| 318 | + | setRemainingPoints(start, offset, row, col) | |
| 319 | + | truncated = true | |
| 320 | + | break | |
| 227 | 321 | } | |
| 228 | - | break | |
| 322 | + | finishRow(true) | |
| 229 | 323 | } | |
| 230 | - | rows = append(rows, row) | |
| 231 | - | bounds = append(bounds, lineBoundary{end: x, soft: true}) | |
| 232 | - | row = make([]renderer.Cell, width) | |
| 233 | - | for j := range row { | |
| 234 | - | row[j] = renderer.BlankCell() | |
| 324 | + | setPoint(start, offset, row, col) | |
| 325 | + | if wide && width >= 2 { | |
| 326 | + | if output != nil { | |
| 327 | + | output[col], output[col+1] = cell, cells[x+1] | |
| 328 | + | } | |
| 329 | + | setPoint(start, offset+1, row, col+1) | |
| 330 | + | col += 2 | |
| 331 | + | x += 2 | |
| 332 | + | offset += 2 | |
| 333 | + | continue | |
| 235 | 334 | } | |
| 236 | - | x = 0 | |
| 237 | - | } | |
| 238 | - | positions[li][i] = point{len(rows), x} | |
| 239 | - | if w == 2 && width >= 2 { | |
| 240 | - | row[x], row[x+1] = line.cells[i], line.cells[i+1] | |
| 241 | - | positions[li][i+1] = point{len(rows), x + 1} | |
| 242 | - | x += 2 | |
| 243 | - | i += 2 | |
| 244 | - | } else { | |
| 245 | - | cell := line.cells[i] | |
| 246 | 335 | if wide { | |
| 247 | 336 | cell.Rune = '\uFFFD' | |
| 248 | 337 | cell.Continuation = false | |
| 249 | 338 | } | |
| 250 | - | row[x] = cell | |
| 339 | + | if output != nil { | |
| 340 | + | output[col] = cell | |
| 341 | + | } | |
| 342 | + | col++ | |
| 251 | 343 | x++ | |
| 252 | - | i++ | |
| 344 | + | offset++ | |
| 253 | 345 | } | |
| 254 | 346 | } | |
| 255 | - | positions[li][len(line.cells)] = point{len(rows), x} | |
| 256 | - | rows = append(rows, row) | |
| 257 | - | bounds = append(bounds, lineBoundary{end: x}) | |
| 258 | - | } | |
| 259 | - | if len(rows) == 0 { | |
| 260 | - | rows = append(rows, make([]renderer.Cell, width)) | |
| 261 | - | for x := range rows[0] { | |
| 262 | - | rows[0][x] = renderer.BlankCell() | |
| 263 | - | } | |
| 264 | - | bounds = append(bounds, lineBoundary{}) | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | anchor := 0 | |
| 268 | - | if len(cursors) > 0 && cursors[0] != nil { | |
| 269 | - | co := cursorOffsets[0] | |
| 270 | - | anchor = positions[co.line][min(co.offset, len(positions[co.line])-1)].row | |
| 271 | - | } | |
| 272 | - | shift := clamp(anchor-(height-1), 0, max(len(rows)-height, 0)) | |
| 273 | - | var evicted [][]renderer.Cell | |
| 274 | - | for y := 0; y < shift; y++ { | |
| 275 | - | evicted = append(evicted, append([]renderer.Cell(nil), rows[y]...)) | |
| 276 | - | } | |
| 277 | - | next := newBuffer(width, height) | |
| 278 | - | for y := 0; y < height && y+shift < len(rows); y++ { | |
| 279 | - | copy(next.frame.Row(y), rows[y+shift]) | |
| 280 | - | next.boundaries[y] = bounds[y+shift] | |
| 281 | - | } | |
| 282 | - | for ci, cur := range cursors { | |
| 283 | - | if cur == nil { | |
| 284 | - | continue | |
| 285 | - | } | |
| 286 | - | co := cursorOffsets[ci] | |
| 287 | - | p := positions[co.line][min(co.offset, len(positions[co.line])-1)] | |
| 288 | - | cur.row, cur.col = clamp(p.row-shift, 0, height-1), clamp(p.col, 0, width) | |
| 347 | + | setPoint(start, offset, row, col) | |
| 348 | + | finishRow(false) | |
| 349 | + | start = end + 1 | |
| 289 | 350 | } | |
| 290 | - | *b = *next | |
| 291 | - | return evicted | |
| 351 | + | return row | |
| 292 | 352 | } | |
| @@ -117,18 +117,28 @@ func (b *buffer) hasSoft() bool { | |||||
| 117 | 117 | ||||
| 118 | // resizeFixed keeps hard physical lines independent. It is the common shell | 118 | // resizeFixed keeps hard physical lines independent. It is the common shell | ||
| 119 | // path and avoids constructing logical-line scratch state when nothing wraps. | 119 | // path and avoids constructing logical-line scratch state when nothing wraps. | ||
| 120 | - | func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]renderer.Cell { | 120 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell { |
| 121 | anchor := 0 | 121 | anchor := 0 | ||
| 122 | - | if len(cursors) > 0 && cursors[0] != nil { | 122 | + | if active != nil { |
| 123 | - | anchor = cursors[0].row | 123 | + | anchor = active.row |
| 124 | } | 124 | } | ||
| 125 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | 125 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | ||
| 126 | evicted := make([][]renderer.Cell, 0, shift) | 126 | evicted := make([][]renderer.Cell, 0, shift) | ||
| 127 | - | for y := 0; y < shift; y++ { | 127 | + | for y := range shift { |
| 128 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | 128 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | ||
| 129 | } | 129 | } | ||
| 130 | - | next := newBuffer(width, height) | 130 | + | // Fixed-line resizes can retain the boundary backing store. In particular, |
| 131 | - | for y := 0; y < height; y++ { | 131 | + | // keep its capacity through a short viewport so the common shrink/grow |
| 132 | + | // sequence does not add metadata allocation to every resize epoch. | |||
| 133 | + | boundaries := b.boundaries | |||
| 134 | + | if cap(boundaries) < height { | |||
| 135 | + | boundaries = make([]lineBoundary, height) | |||
| 136 | + | } else { | |||
| 137 | + | boundaries = boundaries[:height] | |||
| 138 | + | } | |||
| 139 | + | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries} | |||
| 140 | + | copied := 0 | |||
| 141 | + | for y := range height { | |||
| 132 | sy := y + shift | 142 | sy := y + shift | ||
| 133 | if sy >= b.frame.Height { | 143 | if sy >= b.frame.Height { | ||
| 134 | break | 144 | break | ||
| @@ -137,156 +147,206 @@ func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]re | |||||
| 137 | next.boundaries[y] = b.boundaries[sy] | 147 | next.boundaries[y] = b.boundaries[sy] | ||
| 138 | next.boundaries[y].end = min(next.boundaries[y].end, width) | 148 | next.boundaries[y].end = min(next.boundaries[y].end, width) | ||
| 139 | repairFrameRow(next.frame, y) | 149 | repairFrameRow(next.frame, y) | ||
| 150 | + | copied++ | |||
| 140 | } | 151 | } | ||
| 141 | - | for _, cur := range cursors { | 152 | + | clear(next.boundaries[copied:]) |
| 153 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |||
| 142 | if cur != nil { | 154 | if cur != nil { | ||
| 143 | cur.row = clamp(cur.row-shift, 0, height-1) | 155 | cur.row = clamp(cur.row-shift, 0, height-1) | ||
| 144 | cur.col = clamp(cur.col, 0, width) | 156 | cur.col = clamp(cur.col, 0, width) | ||
| 145 | } | 157 | } | ||
| 146 | } | 158 | } | ||
| 147 | - | *b = *next | 159 | + | *b = next |
| 148 | return evicted | 160 | return evicted | ||
| 149 | } | 161 | } | ||
| 150 | 162 | ||||
| 151 | - | // resize lays out only the current grid. It returns the top rows discarded by | 163 | + | type reflowPoint struct { |
| 152 | - | // height reduction and maps every supplied cursor in the same layout pass. | 164 | + | line, offset int |
| 153 | - | func (b *buffer) resize(width, height int, cursors ...*bufferCursor) [][]renderer.Cell { | 165 | + | row, col int |
| 154 | - | b.hydrate() | 166 | + | } |
| 167 | + | ||||
| 168 | + | // cursorReflowPoints maps source cursor positions to offsets in their logical | |||
| 169 | + | // lines. There are exactly two callers (active and DECSC), so this stays flat | |||
| 170 | + | // and stack allocated rather than building a row/column lookup map. | |||
| 171 | + | func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint { | |||
| 172 | + | points := [2]reflowPoint{{line: -1}, {line: -1}} | |||
| 173 | + | cursors := [2]*bufferCursor{active, saved} | |||
| 174 | + | for start := 0; start < b.frame.Height; { | |||
| 175 | + | end := start | |||
| 176 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |||
| 177 | + | end++ | |||
| 178 | + | } | |||
| 179 | + | for i, cur := range cursors { | |||
| 180 | + | if cur == nil || cur.row < start || cur.row > end { | |||
| 181 | + | continue | |||
| 182 | + | } | |||
| 183 | + | offset := 0 | |||
| 184 | + | for y := start; y < cur.row; y++ { | |||
| 185 | + | offset += b.boundaries[y].end | |||
| 186 | + | } | |||
| 187 | + | points[i] = reflowPoint{ | |||
| 188 | + | line: start, | |||
| 189 | + | offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end), | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | start = end + 1 | |||
| 193 | + | } | |||
| 194 | + | return points | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // resize lays out only the current grid. It does two direct, bounded passes: | |||
| 198 | + | // the first maps both cursors and counts rows; the second writes only the | |||
| 199 | + | // retained viewport (and the rows genuinely evicted to history). It never | |||
| 200 | + | // materializes logical lines, per-cell position maps, or temporary output rows. | |||
| 201 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |||
| 155 | if !b.hasSoft() { | 202 | if !b.hasSoft() { | ||
| 156 | - | return b.resizeFixed(width, height, cursors...) | 203 | + | return b.resizeFixed(width, height, active, saved) |
| 157 | } | 204 | } | ||
| 158 | - | for _, cur := range cursors { | 205 | + | // Only reflow needs meaningful extents; hard physical rows are copied as |
| 206 | + | // cells, so scanning blank rows on the shell fast path is wasted work. | |||
| 207 | + | b.hydrate() | |||
| 208 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |||
| 159 | if cur != nil { | 209 | if cur != nil { | ||
| 160 | b.content(cur.row, cur.col) | 210 | b.content(cur.row, cur.col) | ||
| 161 | } | 211 | } | ||
| 162 | } | 212 | } | ||
| 163 | - | oldW := b.frame.Width | 213 | + | |
| 164 | - | type logical struct { | 214 | + | points := b.cursorReflowPoints(active, saved) |
| 165 | - | cells []renderer.Cell | 215 | + | rows := b.layoutReflow(width, &points, nil, 0, nil) |
| 166 | - | points map[int]int // old row/column key -> cell offset | 216 | + | anchor := 0 |
| 167 | - | reflow bool | 217 | + | if active != nil { |
| 218 | + | anchor = points[0].row | |||
| 168 | } | 219 | } | ||
| 169 | - | var lines []logical | 220 | + | shift := clamp(anchor-(height-1), 0, max(rows-height, 0)) |
| 170 | - | for y := 0; y < b.frame.Height; { | 221 | + | |
| 171 | - | line := logical{points: make(map[int]int)} | 222 | + | next := newBuffer(width, height) |
| 172 | - | for { | 223 | + | var evicted [][]renderer.Cell |
| 173 | - | end := b.boundaries[y].end | 224 | + | var evictedCells []renderer.Cell |
| 174 | - | for x := 0; x <= oldW; x++ { | 225 | + | if shift > 0 { |
| 175 | - | line.points[y*(oldW+1)+x] = len(line.cells) + min(x, end) | 226 | + | // History needs owned rows. One flat backing store replaces a temporary |
| 176 | - | } | 227 | + | // allocation per evicted output row. |
| 177 | - | line.cells = append(line.cells, b.frame.Row(y)[:end]...) | 228 | + | evicted = make([][]renderer.Cell, shift) |
| 178 | - | soft := b.boundaries[y].soft && y+1 < b.frame.Height | 229 | + | evictedCells = make([]renderer.Cell, shift*width) |
| 179 | - | line.reflow = line.reflow || soft | 230 | + | for y := range evicted { |
| 180 | - | y++ | 231 | + | evicted[y] = evictedCells[y*width : (y+1)*width] |
| 181 | - | if !soft { | |||
| 182 | - | break | |||
| 183 | - | } | |||
| 184 | } | 232 | } | ||
| 185 | - | lines = append(lines, line) | |||
| 186 | } | 233 | } | ||
| 187 | - | 234 | + | b.layoutReflow(width, &points, next, shift, evictedCells) | |
| 188 | - | type point struct{ row, col int } | 235 | + | for i, cur := range [2]*bufferCursor{active, saved} { |
| 189 | - | cursorOffsets := make([]struct{ line, offset int }, len(cursors)) | 236 | + | if cur != nil { |
| 190 | - | for ci, cur := range cursors { | 237 | + | cur.row = clamp(points[i].row-shift, 0, height-1) |
| 191 | - | if cur == nil { | 238 | + | cur.col = clamp(points[i].col, 0, width) |
| 192 | - | continue | |||
| 193 | } | 239 | } | ||
| 194 | - | key := clamp(cur.row, 0, max(b.frame.Height-1, 0))*(oldW+1) + clamp(cur.col, 0, oldW) | 240 | + | } |
| 195 | - | for li := range lines { | 241 | + | *b = *next |
| 196 | - | if offset, ok := lines[li].points[key]; ok { | 242 | + | return evicted |
| 197 | - | cursorOffsets[ci] = struct{ line, offset int }{li, offset} | 243 | + | } |
| 198 | - | break | 244 | + | |
| 245 | + | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |||
| 246 | + | // [shift, shift+dst.height). Rows before shift are written straight into the | |||
| 247 | + | // contiguous eviction backing store. The return is the logical output height. | |||
| 248 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int { | |||
| 249 | + | row, col := 0, 0 | |||
| 250 | + | blankEvicted := func(outputRow int) []renderer.Cell { | |||
| 251 | + | if outputRow < shift { | |||
| 252 | + | out := evicted[outputRow*width : (outputRow+1)*width] | |||
| 253 | + | for i := range out { | |||
| 254 | + | out[i] = renderer.BlankCell() | |||
| 199 | } | 255 | } | ||
| 256 | + | return out | |||
| 257 | + | } | |||
| 258 | + | if dst != nil && outputRow < shift+dst.frame.Height { | |||
| 259 | + | return dst.frame.Row(outputRow - shift) | |||
| 200 | } | 260 | } | ||
| 261 | + | return nil | |||
| 201 | } | 262 | } | ||
| 202 | - | 263 | + | var output []renderer.Cell | |
| 203 | - | var rows [][]renderer.Cell | 264 | + | if dst != nil && (shift > 0 || dst.frame.Height > 0) { |
| 204 | - | var bounds []lineBoundary | 265 | + | output = blankEvicted(0) |
| 205 | - | positions := make([][]point, len(lines)) | 266 | + | } |
| 206 | - | for li, line := range lines { | 267 | + | finishRow := func(soft bool) { |
| 207 | - | positions[li] = make([]point, len(line.cells)+1) | 268 | + | if dst != nil && row >= shift && row < shift+dst.frame.Height { |
| 208 | - | row := make([]renderer.Cell, width) | 269 | + | dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft} |
| 209 | - | for x := range row { | |||
| 210 | - | row[x] = renderer.BlankCell() | |||
| 211 | } | 270 | } | ||
| 212 | - | x := 0 | 271 | + | row++ |
| 213 | - | for i := 0; i < len(line.cells); { | 272 | + | col = 0 |
| 214 | - | if line.cells[i].Continuation { // corrupt input is repaired below. | 273 | + | if dst != nil { |
| 215 | - | i++ | 274 | + | output = blankEvicted(row) |
| 216 | - | continue | 275 | + | } |
| 276 | + | } | |||
| 277 | + | setPoint := func(line, offset, pointRow, pointCol int) { | |||
| 278 | + | for i := range points { | |||
| 279 | + | if points[i].line == line && points[i].offset == offset { | |||
| 280 | + | points[i].row, points[i].col = pointRow, pointCol | |||
| 217 | } | 281 | } | ||
| 218 | - | w := renderer.RuneWidth(line.cells[i].Rune) | 282 | + | } |
| 219 | - | wide := w == 2 && i+1 < len(line.cells) && line.cells[i+1].Continuation | 283 | + | } |
| 220 | - | if !wide || width < 2 { | 284 | + | setRemainingPoints := func(line, offset, pointRow, pointCol int) { |
| 221 | - | w = 1 | 285 | + | for i := range points { |
| 286 | + | if points[i].line == line && points[i].offset >= offset { | |||
| 287 | + | points[i].row, points[i].col = pointRow, pointCol | |||
| 222 | } | 288 | } | ||
| 223 | - | if x+w > width && x > 0 { | 289 | + | } |
| 224 | - | if !line.reflow { | 290 | + | } |
| 225 | - | for j := i; j <= len(line.cells); j++ { | 291 | + | |
| 226 | - | positions[li][j] = point{len(rows), x} | 292 | + | for start := 0; start < b.frame.Height; { |
| 293 | + | end := start | |||
| 294 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |||
| 295 | + | end++ | |||
| 296 | + | } | |||
| 297 | + | reflow := end > start | |||
| 298 | + | offset := 0 | |||
| 299 | + | truncated := false | |||
| 300 | + | for y := start; y <= end && !truncated; y++ { | |||
| 301 | + | cells := b.frame.Row(y) | |||
| 302 | + | limit := b.boundaries[y].end | |||
| 303 | + | for x := 0; x < limit; { | |||
| 304 | + | cell := cells[x] | |||
| 305 | + | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | |||
| 306 | + | setPoint(start, offset, row, col) | |||
| 307 | + | x++ | |||
| 308 | + | offset++ | |||
| 309 | + | continue | |||
| 310 | + | } | |||
| 311 | + | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation | |||
| 312 | + | w := 1 | |||
| 313 | + | if wide && width >= 2 { | |||
| 314 | + | w = 2 | |||
| 315 | + | } | |||
| 316 | + | if col+w > width && col > 0 { | |||
| 317 | + | if !reflow { | |||
| 318 | + | setRemainingPoints(start, offset, row, col) | |||
| 319 | + | truncated = true | |||
| 320 | + | break | |||
| 227 | } | 321 | } | ||
| 228 | - | break | 322 | + | finishRow(true) |
| 229 | } | 323 | } | ||
| 230 | - | rows = append(rows, row) | 324 | + | setPoint(start, offset, row, col) |
| 231 | - | bounds = append(bounds, lineBoundary{end: x, soft: true}) | 325 | + | if wide && width >= 2 { |
| 232 | - | row = make([]renderer.Cell, width) | 326 | + | if output != nil { |
| 233 | - | for j := range row { | 327 | + | output[col], output[col+1] = cell, cells[x+1] |
| 234 | - | row[j] = renderer.BlankCell() | 328 | + | } |
| 329 | + | setPoint(start, offset+1, row, col+1) | |||
| 330 | + | col += 2 | |||
| 331 | + | x += 2 | |||
| 332 | + | offset += 2 | |||
| 333 | + | continue | |||
| 235 | } | 334 | } | ||
| 236 | - | x = 0 | |||
| 237 | - | } | |||
| 238 | - | positions[li][i] = point{len(rows), x} | |||
| 239 | - | if w == 2 && width >= 2 { | |||
| 240 | - | row[x], row[x+1] = line.cells[i], line.cells[i+1] | |||
| 241 | - | positions[li][i+1] = point{len(rows), x + 1} | |||
| 242 | - | x += 2 | |||
| 243 | - | i += 2 | |||
| 244 | - | } else { | |||
| 245 | - | cell := line.cells[i] | |||
| 246 | if wide { | 335 | if wide { | ||
| 247 | cell.Rune = '\uFFFD' | 336 | cell.Rune = '\uFFFD' | ||
| 248 | cell.Continuation = false | 337 | cell.Continuation = false | ||
| 249 | } | 338 | } | ||
| 250 | - | row[x] = cell | 339 | + | if output != nil { |
| 340 | + | output[col] = cell | |||
| 341 | + | } | |||
| 342 | + | col++ | |||
| 251 | x++ | 343 | x++ | ||
| 252 | - | i++ | 344 | + | offset++ |
| 253 | } | 345 | } | ||
| 254 | } | 346 | } | ||
| 255 | - | positions[li][len(line.cells)] = point{len(rows), x} | 347 | + | setPoint(start, offset, row, col) |
| 256 | - | rows = append(rows, row) | 348 | + | finishRow(false) |
| 257 | - | bounds = append(bounds, lineBoundary{end: x}) | 349 | + | start = end + 1 |
| 258 | - | } | |||
| 259 | - | if len(rows) == 0 { | |||
| 260 | - | rows = append(rows, make([]renderer.Cell, width)) | |||
| 261 | - | for x := range rows[0] { | |||
| 262 | - | rows[0][x] = renderer.BlankCell() | |||
| 263 | - | } | |||
| 264 | - | bounds = append(bounds, lineBoundary{}) | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | anchor := 0 | |||
| 268 | - | if len(cursors) > 0 && cursors[0] != nil { | |||
| 269 | - | co := cursorOffsets[0] | |||
| 270 | - | anchor = positions[co.line][min(co.offset, len(positions[co.line])-1)].row | |||
| 271 | - | } | |||
| 272 | - | shift := clamp(anchor-(height-1), 0, max(len(rows)-height, 0)) | |||
| 273 | - | var evicted [][]renderer.Cell | |||
| 274 | - | for y := 0; y < shift; y++ { | |||
| 275 | - | evicted = append(evicted, append([]renderer.Cell(nil), rows[y]...)) | |||
| 276 | - | } | |||
| 277 | - | next := newBuffer(width, height) | |||
| 278 | - | for y := 0; y < height && y+shift < len(rows); y++ { | |||
| 279 | - | copy(next.frame.Row(y), rows[y+shift]) | |||
| 280 | - | next.boundaries[y] = bounds[y+shift] | |||
| 281 | - | } | |||
| 282 | - | for ci, cur := range cursors { | |||
| 283 | - | if cur == nil { | |||
| 284 | - | continue | |||
| 285 | - | } | |||
| 286 | - | co := cursorOffsets[ci] | |||
| 287 | - | p := positions[co.line][min(co.offset, len(positions[co.line])-1)] | |||
| 288 | - | cur.row, cur.col = clamp(p.row-shift, 0, height-1), clamp(p.col, 0, width) | |||
| 289 | } | 350 | } | ||
| 290 | - | *b = *next | 351 | + | return row |
| 291 | - | return evicted | |||
| 292 | } | 352 | } | ||
A pkg/vt/buffer_cursor_reflow_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestResizeReflowMapsActiveAndSavedCursorAtNewRowBoundary(t *testing.T) { | |
| 6 | + | s := NewScreen(5, 3) | |
| 7 | + | s.Write([]byte("abcdefgh")) | |
| 8 | + | s.Row, s.Col = 0, 3 | |
| 9 | + | s.saveCursor() | |
| 10 | + | ||
| 11 | + | s.Resize(3, 3) | |
| 12 | + | ||
| 13 | + | if s.Row != 1 || s.Col != 0 { | |
| 14 | + | t.Fatalf("active cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |
| 15 | + | } | |
| 16 | + | s.Row, s.Col = 0, 0 | |
| 17 | + | s.restoreCursor() | |
| 18 | + | if s.Row != 1 || s.Col != 0 { | |
| 19 | + | t.Fatalf("saved cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |
| 20 | + | } | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestResizeReflowMapsActiveAndSavedCursorAtNewRowBoundary(t *testing.T) { | |||
| 6 | + | s := NewScreen(5, 3) | |||
| 7 | + | s.Write([]byte("abcdefgh")) | |||
| 8 | + | s.Row, s.Col = 0, 3 | |||
| 9 | + | s.saveCursor() | |||
| 10 | + | ||||
| 11 | + | s.Resize(3, 3) | |||
| 12 | + | ||||
| 13 | + | if s.Row != 1 || s.Col != 0 { | |||
| 14 | + | t.Fatalf("active cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 15 | + | } | |||
| 16 | + | s.Row, s.Col = 0, 0 | |||
| 17 | + | s.restoreCursor() | |||
| 18 | + | if s.Row != 1 || s.Col != 0 { | |||
| 19 | + | t.Fatalf("saved cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 20 | + | } | |||
| 21 | + | } | |||
M pkg/vt/screen_resize.go +1 -2
| @@ -22,8 +22,7 @@ func (s *Screen) Resize(width, height int) { | |||
| 22 | 22 | if saved != nil && saved.saved { | |
| 23 | 23 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | |
| 24 | 24 | } | |
| 25 | - | cursors := []*bufferCursor{active, savedPoint} | |
| 26 | - | evicted := b.resize(width, height, cursors...) | |
| 25 | + | evicted := b.resize(width, height, active, savedPoint) | |
| 27 | 26 | if evict { | |
| 28 | 27 | for _, line := range evicted { | |
| 29 | 28 | s.recordEvicted(line) | |
| @@ -22,8 +22,7 @@ func (s *Screen) Resize(width, height int) { | |||||
| 22 | if saved != nil && saved.saved { | 22 | if saved != nil && saved.saved { | ||
| 23 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | 23 | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | ||
| 24 | } | 24 | } | ||
| 25 | - | cursors := []*bufferCursor{active, savedPoint} | 25 | + | evicted := b.resize(width, height, active, savedPoint) |
| 26 | - | evicted := b.resize(width, height, cursors...) | |||
| 27 | if evict { | 26 | if evict { | ||
| 28 | for _, line := range evicted { | 27 | for _, line := range evicted { | ||
| 29 | s.recordEvicted(line) | 28 | s.recordEvicted(line) | ||
M pkg/vt/snapshot.go +26 -34
| @@ -8,10 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | const ( | |
| 12 | - | visibleMagic = "VTV1" | |
| 13 | - | visibleMagicV2 = "VTV2" | |
| 14 | - | ) | |
| 11 | + | const visibleMagic = "VTV2" | |
| 15 | 12 | ||
| 16 | 13 | // DecodeStats describes resources declared by one canonical VT blob. | |
| 17 | 14 | type DecodeStats struct { | |
| @@ -115,7 +112,16 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale | |||
| 115 | 112 | // MarshalVisible encodes the exact visible primary frame, including every | |
| 116 | 113 | // blank, row width, and terminal cell attribute. | |
| 117 | 114 | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |
| 118 | - | return marshalVisible(frame, nil) | |
| 115 | + | boundaries := make([]lineBoundary, frame.Height) | |
| 116 | + | for y := range boundaries { | |
| 117 | + | for x := frame.Width - 1; x >= 0; x-- { | |
| 118 | + | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 119 | + | boundaries[y].end = x + 1 | |
| 120 | + | break | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | return marshalVisible(frame, boundaries) | |
| 119 | 125 | } | |
| 120 | 126 | ||
| 121 | 127 | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |
| @@ -140,17 +146,12 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er | |||
| 140 | 146 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | |
| 141 | 147 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 142 | 148 | } | |
| 143 | - | boundaryBytes := 0 | |
| 144 | - | magic := visibleMagic | |
| 145 | - | if boundaries != nil { | |
| 146 | - | if len(boundaries) != frame.Height { | |
| 147 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 148 | - | } | |
| 149 | - | boundaryBytes = frame.Height * 5 | |
| 150 | - | magic = visibleMagicV2 | |
| 149 | + | if len(boundaries) != frame.Height { | |
| 150 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 151 | 151 | } | |
| 152 | + | boundaryBytes := frame.Height * 5 | |
| 152 | 153 | out := make([]byte, 0, 13+bytes+uint64(boundaryBytes)) | |
| 153 | - | out = append(out, magic...) | |
| 154 | + | out = append(out, visibleMagic...) | |
| 154 | 155 | out = append(out, historyVersion) | |
| 155 | 156 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| 156 | 157 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |
| @@ -176,7 +177,7 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er | |||
| 176 | 177 | // PreflightVisibleBlob validates dimensions and cell declarations without | |
| 177 | 178 | // allocating a frame. | |
| 178 | 179 | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |
| 179 | - | if len(data) < 13 || (string(data[:4]) != visibleMagic && string(data[:4]) != visibleMagicV2) || data[4] != historyVersion { | |
| 180 | + | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |
| 180 | 181 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 181 | 182 | } | |
| 182 | 183 | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |
| @@ -185,10 +186,7 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 185 | 186 | } | |
| 186 | 187 | cells := width * height | |
| 187 | 188 | bytes, ok := historyCellByteCount(cells) | |
| 188 | - | expected := bytes | |
| 189 | - | if string(data[:4]) == visibleMagicV2 { | |
| 190 | - | expected += uint64(height) * 5 | |
| 191 | - | } | |
| 189 | + | expected := bytes + uint64(height)*5 | |
| 192 | 190 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected { | |
| 193 | 191 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 194 | 192 | } | |
| @@ -199,13 +197,11 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 199 | 197 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 200 | 198 | } | |
| 201 | 199 | } | |
| 202 | - | if string(data[:4]) == visibleMagicV2 { | |
| 203 | - | for range height { | |
| 204 | - | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |
| 205 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 206 | - | } | |
| 207 | - | p.data = p.data[5:] | |
| 200 | + | for range height { | |
| 201 | + | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |
| 202 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 208 | 203 | } | |
| 204 | + | p.data = p.data[5:] | |
| 209 | 205 | } | |
| 210 | 206 | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |
| 211 | 207 | } | |
| @@ -231,12 +227,10 @@ func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) { | |||
| 231 | 227 | } | |
| 232 | 228 | } | |
| 233 | 229 | boundaries := make([]lineBoundary, height) | |
| 234 | - | if string(data[:4]) == visibleMagicV2 { | |
| 235 | - | offset := 13 + int(cellBytes) | |
| 236 | - | for y := range boundaries { | |
| 237 | - | boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1} | |
| 238 | - | offset += 5 | |
| 239 | - | } | |
| 230 | + | offset := 13 + int(cellBytes) | |
| 231 | + | for y := range boundaries { | |
| 232 | + | boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1} | |
| 233 | + | offset += 5 | |
| 240 | 234 | } | |
| 241 | 235 | return frame, boundaries, nil | |
| 242 | 236 | } | |
| @@ -271,9 +265,7 @@ func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |||
| 271 | 265 | return err | |
| 272 | 266 | } | |
| 273 | 267 | b := bufferFromFrame(frame) | |
| 274 | - | if len(boundaries) == frame.Height { | |
| 275 | - | b.boundaries = boundaries | |
| 276 | - | } | |
| 268 | + | b.boundaries = boundaries | |
| 277 | 269 | // A persisted primary frame is authoritative: restoring it directly keeps | |
| 278 | 270 | // exact row widths and height even when a collapsed layout chose a smaller | |
| 279 | 271 | // initial PTY size. A subsequent resize reconciles it with live geometry. | |
| @@ -8,10 +8,7 @@ import ( | |||||
| 8 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | const ( | 11 | + | const visibleMagic = "VTV2" |
| 12 | - | visibleMagic = "VTV1" | |||
| 13 | - | visibleMagicV2 = "VTV2" | |||
| 14 | - | ) | |||
| 15 | 12 | ||||
| 16 | // DecodeStats describes resources declared by one canonical VT blob. | 13 | // DecodeStats describes resources declared by one canonical VT blob. | ||
| 17 | type DecodeStats struct { | 14 | type DecodeStats struct { | ||
| @@ -115,7 +112,16 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale | |||||
| 115 | // MarshalVisible encodes the exact visible primary frame, including every | 112 | // MarshalVisible encodes the exact visible primary frame, including every | ||
| 116 | // blank, row width, and terminal cell attribute. | 113 | // blank, row width, and terminal cell attribute. | ||
| 117 | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | 114 | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | ||
| 118 | - | return marshalVisible(frame, nil) | 115 | + | boundaries := make([]lineBoundary, frame.Height) |
| 116 | + | for y := range boundaries { | |||
| 117 | + | for x := frame.Width - 1; x >= 0; x-- { | |||
| 118 | + | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |||
| 119 | + | boundaries[y].end = x + 1 | |||
| 120 | + | break | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | return marshalVisible(frame, boundaries) | |||
| 119 | } | 125 | } | ||
| 120 | 126 | ||||
| 121 | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | 127 | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | ||
| @@ -140,17 +146,12 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er | |||||
| 140 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | 146 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | ||
| 141 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | 147 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | ||
| 142 | } | 148 | } | ||
| 143 | - | boundaryBytes := 0 | 149 | + | if len(boundaries) != frame.Height { |
| 144 | - | magic := visibleMagic | 150 | + | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) |
| 145 | - | if boundaries != nil { | |||
| 146 | - | if len(boundaries) != frame.Height { | |||
| 147 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 148 | - | } | |||
| 149 | - | boundaryBytes = frame.Height * 5 | |||
| 150 | - | magic = visibleMagicV2 | |||
| 151 | } | 151 | } | ||
| 152 | + | boundaryBytes := frame.Height * 5 | |||
| 152 | out := make([]byte, 0, 13+bytes+uint64(boundaryBytes)) | 153 | out := make([]byte, 0, 13+bytes+uint64(boundaryBytes)) | ||
| 153 | - | out = append(out, magic...) | 154 | + | out = append(out, visibleMagic...) |
| 154 | out = append(out, historyVersion) | 155 | out = append(out, historyVersion) | ||
| 155 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | 156 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | ||
| 156 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | 157 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | ||
| @@ -176,7 +177,7 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er | |||||
| 176 | // PreflightVisibleBlob validates dimensions and cell declarations without | 177 | // PreflightVisibleBlob validates dimensions and cell declarations without | ||
| 177 | // allocating a frame. | 178 | // allocating a frame. | ||
| 178 | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | 179 | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | ||
| 179 | - | if len(data) < 13 || (string(data[:4]) != visibleMagic && string(data[:4]) != visibleMagicV2) || data[4] != historyVersion { | 180 | + | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { |
| 180 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 181 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | ||
| 181 | } | 182 | } | ||
| 182 | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | 183 | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | ||
| @@ -185,10 +186,7 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||||
| 185 | } | 186 | } | ||
| 186 | cells := width * height | 187 | cells := width * height | ||
| 187 | bytes, ok := historyCellByteCount(cells) | 188 | bytes, ok := historyCellByteCount(cells) | ||
| 188 | - | expected := bytes | 189 | + | expected := bytes + uint64(height)*5 |
| 189 | - | if string(data[:4]) == visibleMagicV2 { | |||
| 190 | - | expected += uint64(height) * 5 | |||
| 191 | - | } | |||
| 192 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected { | 190 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected { | ||
| 193 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 191 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | ||
| 194 | } | 192 | } | ||
| @@ -199,13 +197,11 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||||
| 199 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 197 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | ||
| 200 | } | 198 | } | ||
| 201 | } | 199 | } | ||
| 202 | - | if string(data[:4]) == visibleMagicV2 { | 200 | + | for range height { |
| 203 | - | for range height { | 201 | + | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { |
| 204 | - | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | 202 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) |
| 205 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 206 | - | } | |||
| 207 | - | p.data = p.data[5:] | |||
| 208 | } | 203 | } | ||
| 204 | + | p.data = p.data[5:] | |||
| 209 | } | 205 | } | ||
| 210 | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | 206 | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | ||
| 211 | } | 207 | } | ||
| @@ -231,12 +227,10 @@ func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) { | |||||
| 231 | } | 227 | } | ||
| 232 | } | 228 | } | ||
| 233 | boundaries := make([]lineBoundary, height) | 229 | boundaries := make([]lineBoundary, height) | ||
| 234 | - | if string(data[:4]) == visibleMagicV2 { | 230 | + | offset := 13 + int(cellBytes) |
| 235 | - | offset := 13 + int(cellBytes) | 231 | + | for y := range boundaries { |
| 236 | - | for y := range boundaries { | 232 | + | boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1} |
| 237 | - | boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1} | 233 | + | offset += 5 |
| 238 | - | offset += 5 | |||
| 239 | - | } | |||
| 240 | } | 234 | } | ||
| 241 | return frame, boundaries, nil | 235 | return frame, boundaries, nil | ||
| 242 | } | 236 | } | ||
| @@ -271,9 +265,7 @@ func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |||||
| 271 | return err | 265 | return err | ||
| 272 | } | 266 | } | ||
| 273 | b := bufferFromFrame(frame) | 267 | b := bufferFromFrame(frame) | ||
| 274 | - | if len(boundaries) == frame.Height { | 268 | + | b.boundaries = boundaries |
| 275 | - | b.boundaries = boundaries | |||
| 276 | - | } | |||
| 277 | // A persisted primary frame is authoritative: restoring it directly keeps | 269 | // A persisted primary frame is authoritative: restoring it directly keeps | ||
| 278 | // exact row widths and height even when a collapsed layout chose a smaller | 270 | // exact row widths and height even when a collapsed layout chose a smaller | ||
| 279 | // initial PTY size. A subsequent resize reconciles it with live geometry. | 271 | // initial PTY size. A subsequent resize reconciles it with live geometry. | ||
M pkg/vt/snapshot_boundary_test.go +8 -1
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func TestPreflightVisibleV2RejectsMalformedBoundaries(t *testing.T) { | |
| 10 | + | func TestPreflightVisibleRejectsMalformedBoundariesAndLegacyFormat(t *testing.T) { | |
| 11 | 11 | s := NewScreen(4, 2) | |
| 12 | 12 | s.Write([]byte("abcdef")) | |
| 13 | 13 | blob, err := s.MarshalPrimaryVisible() | |
| @@ -40,4 +40,11 @@ func TestPreflightVisibleV2RejectsMalformedBoundaries(t *testing.T) { | |||
| 40 | 40 | require.Error(t, err) | |
| 41 | 41 | }) | |
| 42 | 42 | } | |
| 43 | + | ||
| 44 | + | t.Run("legacy VTV1 omits boundaries", func(t *testing.T) { | |
| 45 | + | legacy := append([]byte(nil), blob[:boundaryOffset]...) | |
| 46 | + | copy(legacy[:4], "VTV1") | |
| 47 | + | _, err := PreflightVisibleBlob(legacy) | |
| 48 | + | require.Error(t, err) | |
| 49 | + | }) | |
| 43 | 50 | } | |
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | - | func TestPreflightVisibleV2RejectsMalformedBoundaries(t *testing.T) { | 10 | + | func TestPreflightVisibleRejectsMalformedBoundariesAndLegacyFormat(t *testing.T) { |
| 11 | s := NewScreen(4, 2) | 11 | s := NewScreen(4, 2) | ||
| 12 | s.Write([]byte("abcdef")) | 12 | s.Write([]byte("abcdef")) | ||
| 13 | blob, err := s.MarshalPrimaryVisible() | 13 | blob, err := s.MarshalPrimaryVisible() | ||
| @@ -40,4 +40,11 @@ func TestPreflightVisibleV2RejectsMalformedBoundaries(t *testing.T) { | |||||
| 40 | require.Error(t, err) | 40 | require.Error(t, err) | ||
| 41 | }) | 41 | }) | ||
| 42 | } | 42 | } | ||
| 43 | + | ||||
| 44 | + | t.Run("legacy VTV1 omits boundaries", func(t *testing.T) { | |||
| 45 | + | legacy := append([]byte(nil), blob[:boundaryOffset]...) | |||
| 46 | + | copy(legacy[:4], "VTV1") | |||
| 47 | + | _, err := PreflightVisibleBlob(legacy) | |||
| 48 | + | require.Error(t, err) | |||
| 49 | + | }) | |||
| 43 | } | 50 | } | ||