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