detached b93fa1a

perf(vt): streamline viewport reflow

commit
b93fa1a683dd8386172ff6e97be9a228297a5d37
author
brice · · 2026-07-15 16:00 UTC
parent
f6aceff
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
117117
118118 // resizeFixed keeps hard physical lines independent. It is the common shell
119119 // 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 {
121121 anchor := 0
122- if len(cursors) > 0 && cursors[0] != nil {
123- anchor = cursors[0].row
122+ if active != nil {
123+ anchor = active.row
124124 }
125125 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
126126 evicted := make([][]renderer.Cell, 0, shift)
127- for y := 0; y < shift; y++ {
127+ for y := range shift {
128128 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
129129 }
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 {
132142 sy := y + shift
133143 if sy >= b.frame.Height {
134144 break
@@ -137,156 +147,206 @@ func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]re
137147 next.boundaries[y] = b.boundaries[sy]
138148 next.boundaries[y].end = min(next.boundaries[y].end, width)
139149 repairFrameRow(next.frame, y)
150+ copied++
140151 }
141- for _, cur := range cursors {
152+ clear(next.boundaries[copied:])
153+ for _, cur := range [2]*bufferCursor{active, saved} {
142154 if cur != nil {
143155 cur.row = clamp(cur.row-shift, 0, height-1)
144156 cur.col = clamp(cur.col, 0, width)
145157 }
146158 }
147- *b = *next
159+ *b = next
148160 return evicted
149161 }
150162
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 {
155202 if !b.hasSoft() {
156- return b.resizeFixed(width, height, cursors...)
203+ return b.resizeFixed(width, height, active, saved)
157204 }
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} {
159209 if cur != nil {
160210 b.content(cur.row, cur.col)
161211 }
162212 }
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
168219 }
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]
184232 }
185- lines = append(lines, line)
186233 }
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)
193239 }
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()
199255 }
256+ return out
257+ }
258+ if dst != nil && outputRow < shift+dst.frame.Height {
259+ return dst.frame.Row(outputRow - shift)
200260 }
261+ return nil
201262 }
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}
211270 }
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
217281 }
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
222288 }
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
227321 }
228- break
322+ finishRow(true)
229323 }
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
235334 }
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]
246335 if wide {
247336 cell.Rune = '\uFFFD'
248337 cell.Continuation = false
249338 }
250- row[x] = cell
339+ if output != nil {
340+ output[col] = cell
341+ }
342+ col++
251343 x++
252- i++
344+ offset++
253345 }
254346 }
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
289350 }
290- *b = *next
291- return evicted
351+ return row
292352 }
@@ -117,18 +117,28 @@ func (b *buffer) hasSoft() bool {
117 117
118 // resizeFixed keeps hard physical lines independent. It is the common shell118 // 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 := 0121 anchor := 0
122- if len(cursors) > 0 && cursors[0] != nil {122+ if active != nil {
123- anchor = cursors[0].row123+ 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 + shift142 sy := y + shift
133 if sy >= b.frame.Height {143 if sy >= b.frame.Height {
134 break144 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 = *next159+ *b = next
148 return evicted160 return evicted
149 }161 }
150 162
151-// resize lays out only the current grid. It returns the top rows discarded by163+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.Width213+
164- type logical struct {214+ points := b.cursorReflowPoints(active, saved)
165- cells []renderer.Cell215+ rows := b.layoutReflow(width, &points, nil, 0, nil)
166- points map[int]int // old row/column key -> cell offset216+ anchor := 0
167- reflow bool217+ if active != nil {
218+ anchor = points[0].row
168 }219 }
169- var lines []logical220+ 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].end224+ 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.Height229+ evictedCells = make([]renderer.Cell, shift*width)
179- line.reflow = line.reflow || soft230+ 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- break244+
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.Cell264+ if dst != nil && (shift > 0 || dst.frame.Height > 0) {
204- var bounds []lineBoundary265+ 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 := 0271+ 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- continue275+ }
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].Continuation283+ }
220- if !wide || width < 2 {284+ setRemainingPoints := func(line, offset, pointRow, pointCol int) {
221- w = 1285+ 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- break322+ 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 = false337 cell.Continuation = false
249 }338 }
250- row[x] = cell339+ 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 = *next351+ 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) {
2222 if saved != nil && saved.saved {
2323 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
2424 }
25- cursors := []*bufferCursor{active, savedPoint}
26- evicted := b.resize(width, height, cursors...)
25+ evicted := b.resize(width, height, active, savedPoint)
2726 if evict {
2827 for _, line := range evicted {
2928 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 (
88 "github.com/bnema/vev/pkg/renderer"
99 )
1010
11-const (
12- visibleMagic = "VTV1"
13- visibleMagicV2 = "VTV2"
14-)
11+const visibleMagic = "VTV2"
1512
1613 // DecodeStats describes resources declared by one canonical VT blob.
1714 type DecodeStats struct {
@@ -115,7 +112,16 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale
115112 // MarshalVisible encodes the exact visible primary frame, including every
116113 // blank, row width, and terminal cell attribute.
117114 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)
119125 }
120126
121127 // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
@@ -140,17 +146,12 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er
140146 if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
141147 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
142148 }
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)
151151 }
152+ boundaryBytes := frame.Height * 5
152153 out := make([]byte, 0, 13+bytes+uint64(boundaryBytes))
153- out = append(out, magic...)
154+ out = append(out, visibleMagic...)
154155 out = append(out, historyVersion)
155156 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
156157 out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))
@@ -176,7 +177,7 @@ func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, er
176177 // PreflightVisibleBlob validates dimensions and cell declarations without
177178 // allocating a frame.
178179 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 {
180181 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
181182 }
182183 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) {
185186 }
186187 cells := width * height
187188 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
192190 if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected {
193191 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
194192 }
@@ -199,13 +197,11 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
199197 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
200198 }
201199 }
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)
208203 }
204+ p.data = p.data[5:]
209205 }
210206 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
211207 }
@@ -231,12 +227,10 @@ func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
231227 }
232228 }
233229 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
240234 }
241235 return frame, boundaries, nil
242236 }
@@ -271,9 +265,7 @@ func (s *Screen) RestorePrimaryVisible(blob []byte) error {
271265 return err
272266 }
273267 b := bufferFromFrame(frame)
274- if len(boundaries) == frame.Height {
275- b.boundaries = boundaries
276- }
268+ b.boundaries = boundaries
277269 // A persisted primary frame is authoritative: restoring it directly keeps
278270 // exact row widths and height even when a collapsed layout chose a smaller
279271 // 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 every112 // 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 boundaries127 // 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 := 0149+ if len(boundaries) != frame.Height {
144- magic := visibleMagic150+ 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 without177 // 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 * height187 cells := width * height
187 bytes, ok := historyCellByteCount(cells)188 bytes, ok := historyCellByteCount(cells)
188- expected := bytes189+ 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}, nil206 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, nil235 return frame, boundaries, nil
242 }236 }
@@ -271,9 +265,7 @@ func (s *Screen) RestorePrimaryVisible(blob []byte) error {
271 return err265 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 keeps269 // A persisted primary frame is authoritative: restoring it directly keeps
278 // exact row widths and height even when a collapsed layout chose a smaller270 // 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 (
77 "github.com/stretchr/testify/require"
88 )
99
10-func TestPreflightVisibleV2RejectsMalformedBoundaries(t *testing.T) {
10+func TestPreflightVisibleRejectsMalformedBoundariesAndLegacyFormat(t *testing.T) {
1111 s := NewScreen(4, 2)
1212 s.Write([]byte("abcdef"))
1313 blob, err := s.MarshalPrimaryVisible()
@@ -40,4 +40,11 @@ func TestPreflightVisibleV2RejectsMalformedBoundaries(t *testing.T) {
4040 require.Error(t, err)
4141 })
4242 }
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+ })
4350 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close