1package vt
2
3import "github.com/bnema/vev/pkg/renderer"
4
5// buffer owns the visible VT grid and the physical-row boundaries needed to
6// reconstruct logical lines. History deliberately is not part of this type:
7// reflow is bounded by the live grid.
8type buffer struct {
9 frame renderer.Frame
10 boundaries []LineBound
11}
12
13// LineBound describes a physical row's logical extent. End is exclusive: it is
14// the count of meaningful cells, so the last significant column is End-1. It
15// excludes padding introduced when a wide rune was moved off the right edge.
16// Soft reports that the row continues into the following physical row.
17type LineBound struct {
18 End int
19 Soft bool
20}
21
22func newBuffer(width, height int) *buffer {
23 return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)}
24}
25
26func bufferFromFrame(frame renderer.Frame) *buffer {
27 return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)}
28}
29
30func (b *buffer) clone() *buffer {
31 out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)}
32 return out
33}
34
35func (b *buffer) content(y, end int) {
36 if y < 0 || y >= len(b.boundaries) {
37 return
38 }
39 b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
40}
41
42// bound reports the logical extent of row y. Callers on eviction paths may run
43// against a nil or shorter buffer than the frame they are walking, so an
44// unknown row reads as a hard row of no extent rather than panicking.
45func (b *buffer) bound(y int) LineBound {
46 if b == nil || y < 0 || y >= len(b.boundaries) {
47 return LineBound{}
48 }
49 return b.boundaries[y]
50}
51
52func (b *buffer) truncate(y, end int) {
53 if y < 0 || y >= len(b.boundaries) {
54 return
55 }
56 b.boundaries[y].End = min(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
57}
58
59// insert retains the meaningful shifted tail when insertion happens within it.
60func (b *buffer) insert(y, at, width int) {
61 if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].End {
62 return
63 }
64 b.boundaries[y].End = min(b.boundaries[y].End+width, b.frame.Width)
65}
66
67func (b *buffer) hard(y int) {
68 if y >= 0 && y < len(b.boundaries) {
69 b.boundaries[y].Soft = false
70 }
71}
72
73func (b *buffer) soft(y int) {
74 if y >= 0 && y < len(b.boundaries) {
75 b.boundaries[y].End = b.frame.Width
76 b.boundaries[y].Soft = true
77 }
78}
79
80func (b *buffer) continueRow(y int) {
81 if y >= 0 && y < len(b.boundaries) {
82 b.boundaries[y].Soft = true
83 }
84}
85
86func (b *buffer) clear(y, x0, x1 int) {
87 if y < 0 || y >= len(b.boundaries) {
88 return
89 }
90 b.content(y, x1)
91 if x1 >= b.frame.Width {
92 // Erasing through the right edge leaves nothing flowing onto the next
93 // row: the logical line ends here. Keeping a stale soft link would let
94 // reflow merge a repainted row with the unrelated row below it.
95 b.boundaries[y].Soft = false
96 }
97 if x0 == 0 && x1 >= b.frame.Width {
98 b.boundaries[y] = LineBound{}
99 }
100}
101
102func (b *buffer) scrollUp(top, bottom, n int) {
103 // A region operation changes which rows meet at both region edges. A soft
104 // boundary belongs to the row it follows, not the cells moved into its
105 // neighbor, so sever links crossing either edge before reflow can observe
106 // them.
107 b.hard(top - 1)
108 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
109 for y := bottom - n + 1; y <= bottom; y++ {
110 b.boundaries[y] = LineBound{}
111 }
112 b.hard(bottom - n)
113}
114
115func (b *buffer) scrollDown(top, bottom, n int) {
116 // See scrollUp: the old top no longer follows the row above the region, and
117 // the last moved row no longer precedes the row below it.
118 b.hard(top - 1)
119 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
120 for y := top; y < top+n; y++ {
121 b.boundaries[y] = LineBound{}
122 }
123 b.hard(bottom)
124}
125
126func (b *buffer) hydrate() {
127 for y := range b.boundaries {
128 if b.boundaries[y].End != 0 {
129 continue
130 }
131 for x := b.frame.Width - 1; x >= 0; x-- {
132 if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
133 b.boundaries[y].End = x + 1
134 break
135 }
136 }
137 }
138}
139
140type bufferCursor struct{ row, col int }
141
142func (b *buffer) hasSoft() bool {
143 for _, boundary := range b.boundaries {
144 if boundary.Soft {
145 return true
146 }
147 }
148 return false
149}
150
151// resizeFixed keeps hard physical lines independent. It is the common shell
152// path and avoids constructing logical-line scratch state when nothing wraps.
153func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
154 anchor := 0
155 if active != nil {
156 anchor = active.row
157 }
158 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
159 evicted := make([][]renderer.Cell, 0, shift)
160 evictedBounds := make([]LineBound, 0, shift)
161 for y := range shift {
162 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163 // The evicted row keeps its source width, so its extent needs no clamping.
164 evictedBounds = append(evictedBounds, b.boundaries[y])
165 }
166 // Fixed-line resizes can retain the boundary backing store. In particular,
167 // keep its capacity through a short viewport so the common shrink/grow
168 // sequence does not add metadata allocation to every resize epoch.
169 boundaries := b.boundaries
170 if cap(boundaries) < height {
171 boundaries = make([]LineBound, height)
172 } else {
173 boundaries = boundaries[:height]
174 }
175 next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries}
176 copied := 0
177 for y := range height {
178 sy := y + shift
179 if sy >= b.frame.Height {
180 break
181 }
182 copy(next.frame.Row(y), b.frame.Row(sy))
183 next.boundaries[y] = b.boundaries[sy]
184 next.boundaries[y].End = min(next.boundaries[y].End, width)
185 repairFrameRow(next.frame, y)
186 copied++
187 }
188 clear(next.boundaries[copied:])
189 for _, cur := range [2]*bufferCursor{active, saved} {
190 if cur != nil {
191 cur.row = clamp(cur.row-shift, 0, height-1)
192 cur.col = clamp(cur.col, 0, width)
193 }
194 }
195 *b = next
196 return evicted, evictedBounds
197}
198
199type reflowPoint struct {
200 line, offset int
201 row, col int
202}
203
204// cursorReflowPoints maps source cursor positions to offsets in their logical
205// lines. There are exactly two callers (active and DECSC), so this stays flat
206// and stack allocated rather than building a row/column lookup map.
207func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint {
208 points := [2]reflowPoint{{line: -1}, {line: -1}}
209 cursors := [2]*bufferCursor{active, saved}
210 for start := 0; start < b.frame.Height; {
211 end := start
212 for b.boundaries[end].Soft && end+1 < b.frame.Height {
213 end++
214 }
215 for i, cur := range cursors {
216 if cur == nil || cur.row < start || cur.row > end {
217 continue
218 }
219 offset := 0
220 for y := start; y < cur.row; y++ {
221 offset += b.boundaries[y].End
222 }
223 points[i] = reflowPoint{
224 line: start,
225 offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].End),
226 }
227 }
228 start = end + 1
229 }
230 return points
231}
232
233// resize lays out only the current grid. It does two direct, bounded passes:
234// the first maps both cursors and counts rows; the second writes only the
235// retained viewport (and the rows genuinely evicted to history). It never
236// materializes logical lines, per-cell position maps, or temporary output rows.
237func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
238 if !b.hasSoft() {
239 return b.resizeFixed(width, height, active, saved)
240 }
241 // Only reflow needs meaningful extents; hard physical rows are copied as
242 // cells, so scanning blank rows on the shell fast path is wasted work.
243 b.hydrate()
244 for _, cur := range [2]*bufferCursor{active, saved} {
245 if cur != nil {
246 b.content(cur.row, cur.col)
247 }
248 }
249
250 points := b.cursorReflowPoints(active, saved)
251 rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
252 anchor := 0
253 if active != nil {
254 anchor = points[0].row
255 }
256 shift := clamp(anchor-(height-1), 0, max(rows-height, 0))
257
258 next := newBuffer(width, height)
259 var evicted [][]renderer.Cell
260 var evictedCells []renderer.Cell
261 var evictedBounds []LineBound
262 if shift > 0 {
263 // History needs owned rows. One flat backing store replaces a temporary
264 // allocation per evicted output row.
265 evicted = make([][]renderer.Cell, shift)
266 evictedCells = make([]renderer.Cell, shift*width)
267 evictedBounds = make([]LineBound, shift)
268 for y := range evicted {
269 evicted[y] = evictedCells[y*width : (y+1)*width]
270 }
271 }
272 b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
273 for i, cur := range [2]*bufferCursor{active, saved} {
274 if cur != nil {
275 cur.row = clamp(points[i].row-shift, 0, height-1)
276 cur.col = clamp(points[i].col, 0, width)
277 }
278 }
279 *b = *next
280 return evicted, evictedBounds
281}
282
283// layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
284// [shift, shift+dst.height). Rows before shift are written straight into the
285// contiguous eviction backing store. The return is the logical output height.
286func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
287 row, col := 0, 0
288 blankEvicted := func(outputRow int) []renderer.Cell {
289 if outputRow < shift {
290 out := evicted[outputRow*width : (outputRow+1)*width]
291 for i := range out {
292 out[i] = renderer.BlankCell()
293 }
294 return out
295 }
296 if dst != nil && outputRow < shift+dst.frame.Height {
297 return dst.frame.Row(outputRow - shift)
298 }
299 return nil
300 }
301 var output []renderer.Cell
302 if dst != nil && (shift > 0 || dst.frame.Height > 0) {
303 output = blankEvicted(0)
304 }
305 finishRow := func(soft bool) {
306 // An output row belongs either to history or to the retained viewport, and
307 // only this pass knows where it ended, so both destinations are written here.
308 bound := LineBound{End: col, Soft: soft}
309 switch {
310 case row < shift:
311 if evictedBounds != nil {
312 evictedBounds[row] = bound
313 }
314 case dst != nil && row < shift+dst.frame.Height:
315 dst.boundaries[row-shift] = bound
316 }
317 row++
318 col = 0
319 if dst != nil {
320 output = blankEvicted(row)
321 }
322 }
323 setPoint := func(line, offset, pointRow, pointCol int) {
324 for i := range points {
325 if points[i].line == line && points[i].offset == offset {
326 points[i].row, points[i].col = pointRow, pointCol
327 }
328 }
329 }
330 setRemainingPoints := func(line, offset, pointRow, pointCol int) {
331 for i := range points {
332 if points[i].line == line && points[i].offset >= offset {
333 points[i].row, points[i].col = pointRow, pointCol
334 }
335 }
336 }
337
338 for start := 0; start < b.frame.Height; {
339 end := start
340 for b.boundaries[end].Soft && end+1 < b.frame.Height {
341 end++
342 }
343 reflow := end > start
344 offset := 0
345 truncated := false
346 for y := start; y <= end && !truncated; y++ {
347 cells := b.frame.Row(y)
348 limit := b.boundaries[y].End
349 for x := 0; x < limit; {
350 cell := cells[x]
351 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
352 setPoint(start, offset, row, col)
353 x++
354 offset++
355 continue
356 }
357 wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation
358 w := 1
359 if wide && width >= 2 {
360 w = 2
361 }
362 if col+w > width && col > 0 {
363 if !reflow {
364 setRemainingPoints(start, offset, row, col)
365 truncated = true
366 break
367 }
368 finishRow(true)
369 }
370 setPoint(start, offset, row, col)
371 if wide && width >= 2 {
372 if output != nil {
373 output[col], output[col+1] = cell, cells[x+1]
374 }
375 setPoint(start, offset+1, row, col+1)
376 col += 2
377 x += 2
378 offset += 2
379 continue
380 }
381 if wide {
382 cell.Rune = '\uFFFD'
383 cell.Continuation = false
384 }
385 if output != nil {
386 output[col] = cell
387 }
388 col++
389 x++
390 offset++
391 }
392 }
393 setPoint(start, offset, row, col)
394 finishRow(false)
395 start = end + 1
396 }
397 return row
398}