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