detached 1beec74

Merge pull request #98 from bnema/fix/viewport-resize-reflow

fix(vt): reflow visible lines on terminal resize
commit
1beec74538912917d7820f90ec05e7073d085956
author
bnema · · 2026-07-15 21:13 UTC
committer
GitHub · · 2026-07-15 21:13 UTC
merge of
042317d + 5a60cb3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +879 -89
M internal/usecase/daemon/output_state_stream_test.go +30 -0
@@ -77,6 +77,36 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
7777 return stream.frame(data, reset, echoAck), true, nil
7878 }
7979
80+// A resize is a state reset, but it must not turn subsequent idle or damaged
81+// renders into full frames.
82+func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
83+ screen := vt.NewScreen(4, 2)
84+ screen.Write([]byte("abcd"))
85+ screen.ClearDamage()
86+ screen.Resize(6, 2)
87+
88+ stream := newOutputStateStream()
89+ resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0)
90+ require.NoError(t, err)
91+ require.True(t, ok)
92+ resizeOutput, err := ports.UnmarshalOutput(resized.Payload)
93+ require.NoError(t, err)
94+ require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame")
95+ screen.ClearDamage()
96+
97+ _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
98+ require.NoError(t, err)
99+ require.False(t, ok, "an unchanged render after resize must emit nothing")
100+
101+ screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
102+ damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
103+ require.NoError(t, err)
104+ require.True(t, ok)
105+ damageOutput, err := ports.UnmarshalOutput(damaged.Payload)
106+ require.NoError(t, err)
107+ require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental")
108+}
109+
80110 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
81111 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
82112 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
@@ -77,6 +77,36 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
77 return stream.frame(data, reset, echoAck), true, nil77 return stream.frame(data, reset, echoAck), true, nil
78 }78 }
79 79
80+// A resize is a state reset, but it must not turn subsequent idle or damaged
81+// renders into full frames.
82+func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
83+ screen := vt.NewScreen(4, 2)
84+ screen.Write([]byte("abcd"))
85+ screen.ClearDamage()
86+ screen.Resize(6, 2)
87+
88+ stream := newOutputStateStream()
89+ resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0)
90+ require.NoError(t, err)
91+ require.True(t, ok)
92+ resizeOutput, err := ports.UnmarshalOutput(resized.Payload)
93+ require.NoError(t, err)
94+ require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame")
95+ screen.ClearDamage()
96+
97+ _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
98+ require.NoError(t, err)
99+ require.False(t, ok, "an unchanged render after resize must emit nothing")
100+
101+ screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
102+ damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
103+ require.NoError(t, err)
104+ require.True(t, ok)
105+ damageOutput, err := ports.UnmarshalOutput(damaged.Payload)
106+ require.NoError(t, err)
107+ require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental")
108+}
109+
80 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {110 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
81 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)111 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
82 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)112 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
M internal/usecase/daemon/snapshot.go +16 -15
@@ -10,7 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/usecase/layout"
1212 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/pkg/renderer"
1413 "github.com/bnema/vev/pkg/vt"
1514 )
1615
@@ -44,12 +43,13 @@ type snapshotCaptureTab struct {
4443 }
4544
4645 type snapshotCapturePane struct {
47- id layout.PaneID
48- stableID string
49- cwd string
50- history vt.HistoryView
51- visible renderer.Frame
52- process *snapcodec.Process
46+ id layout.PaneID
47+ stableID string
48+ cwd string
49+ history vt.HistoryView
50+ visible []byte
51+ visibleErr error
52+ process *snapcodec.Process
5353 }
5454
5555 const (
@@ -463,11 +463,13 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
463463 if pty != nil {
464464 pid = pty.Pid()
465465 }
466+ visible, visibleErr := p.screen.MarshalPrimaryVisible()
466467 paneCapture := snapshotCapturePane{
467- id: p.id,
468- stableID: p.stableID,
469- history: p.history.SealAndView(),
470- visible: p.screen.PrimaryVisibleFrame(),
468+ id: p.id,
469+ stableID: p.stableID,
470+ history: p.history.SealAndView(),
471+ visible: visible,
472+ visibleErr: visibleErr,
471473 }
472474 p.mu.Unlock()
473475 paneCapture.cwd = fallbackCwd
@@ -502,11 +504,10 @@ func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error)
502504 paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
503505 for _, paneCapture := range tabCapture.panes {
504506 sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
505- visible, visibleErr := vt.MarshalVisible(paneCapture.visible)
506- if historyErr != nil || visibleErr != nil {
507- return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr)
507+ if historyErr != nil || paneCapture.visibleErr != nil {
508+ return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, paneCapture.visibleErr)
508509 }
509- tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process})
510+ tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: paneCapture.visible, Process: paneCapture.process})
510511 paneIDs[paneCapture.id] = struct{}{}
511512 }
512513 if tabSnap.Tree != nil {
@@ -10,7 +10,6 @@ import (
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/pkg/renderer"
14 "github.com/bnema/vev/pkg/vt"13 "github.com/bnema/vev/pkg/vt"
15 )14 )
16 15
@@ -44,12 +43,13 @@ type snapshotCaptureTab struct {
44 }43 }
45 44
46 type snapshotCapturePane struct {45 type snapshotCapturePane struct {
47- id layout.PaneID46+ id layout.PaneID
48- stableID string47+ stableID string
49- cwd string48+ cwd string
50- history vt.HistoryView49+ history vt.HistoryView
51- visible renderer.Frame50+ visible []byte
52- process *snapcodec.Process51+ visibleErr error
52+ process *snapcodec.Process
53 }53 }
54 54
55 const (55 const (
@@ -463,11 +463,13 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
463 if pty != nil {463 if pty != nil {
464 pid = pty.Pid()464 pid = pty.Pid()
465 }465 }
466+ visible, visibleErr := p.screen.MarshalPrimaryVisible()
466 paneCapture := snapshotCapturePane{467 paneCapture := snapshotCapturePane{
467- id: p.id,468+ id: p.id,
468- stableID: p.stableID,469+ stableID: p.stableID,
469- history: p.history.SealAndView(),470+ history: p.history.SealAndView(),
470- visible: p.screen.PrimaryVisibleFrame(),471+ visible: visible,
472+ visibleErr: visibleErr,
471 }473 }
472 p.mu.Unlock()474 p.mu.Unlock()
473 paneCapture.cwd = fallbackCwd475 paneCapture.cwd = fallbackCwd
@@ -502,11 +504,10 @@ func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error)
502 paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))504 paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes))
503 for _, paneCapture := range tabCapture.panes {505 for _, paneCapture := range tabCapture.panes {
504 sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)506 sealed, tail, historyErr := vt.MarshalSealedHistory(paneCapture.history)
505- visible, visibleErr := vt.MarshalVisible(paneCapture.visible)507+ if historyErr != nil || paneCapture.visibleErr != nil {
506- if historyErr != nil || visibleErr != nil {508+ return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, paneCapture.visibleErr)
507- return nil, fmt.Errorf("terminal snapshot pane %s: history=%w visible=%w", paneCapture.id, historyErr, visibleErr)
508 }509 }
509- tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: paneCapture.process})510+ tabSnap.Panes = append(tabSnap.Panes, snapcodec.Pane{ID: paneCapture.id, StableID: paneCapture.stableID, Cwd: paneCapture.cwd, SealedChunks: sealed, Tail: tail, Visible: paneCapture.visible, Process: paneCapture.process})
510 paneIDs[paneCapture.id] = struct{}{}511 paneIDs[paneCapture.id] = struct{}{}
511 }512 }
512 if tabSnap.Tree != nil {513 if tabSnap.Tree != nil {
M internal/usecase/daemon/transactional_resize_test.go +3 -0
@@ -357,6 +357,9 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
357357 // through S2 immediately (subject to ACK/sync gates).
358358 frame := <-frames
359359 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
360+ output, err := ports.UnmarshalOutput(frame.Payload)
361+ require.NoError(t, err)
362+ require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state")
360363 select {
361364 case extra := <-frames:
362365 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
@@ -357,6 +357,9 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
357 // through S2 immediately (subject to ACK/sync gates).357 // through S2 immediately (subject to ACK/sync gates).
358 frame := <-frames358 frame := <-frames
359 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")359 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
360+ output, err := ports.UnmarshalOutput(frame.Payload)
361+ require.NoError(t, err)
362+ require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state")
360 select {363 select {
361 case extra := <-frames:364 case extra := <-frames:
362 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)365 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
M pkg/vt/benchmark_test.go +33 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "bytes"
45 "testing"
56
67 "github.com/bnema/vev/pkg/renderer"
@@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
159160 return history
160161 }
161162
163+// BenchmarkScreenResizeReflowViewport verifies resize only visits the live
164+// viewport. The 10K-history variant must track the control rather than history
165+// length; history is deliberately populated without changing the viewport.
166+func BenchmarkScreenResizeReflowViewport(b *testing.B) {
167+ b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) })
168+ b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) })
169+}
170+
171+func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
172+ b.Helper()
173+ s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
174+ if historyRows > 0 {
175+ for range historyRows {
176+ s.history.Append(make([]renderer.Cell, 120))
177+ }
178+ }
179+ s.Write(bytes.Repeat([]byte("x"), 120*39))
180+ s.ClearDamage()
181+ sizes := [2][2]int{{80, 40}, {120, 40}}
182+
183+ b.ReportAllocs()
184+ b.ResetTimer()
185+ for i := 0; b.Loop(); i++ {
186+ s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1])
187+ s.ClearDamage()
188+ }
189+ b.StopTimer()
190+ if got := s.History(); historyRows > 0 && got.Len() < historyRows {
191+ b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows)
192+ }
193+}
194+
162195 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
163196 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
164197 b.ReportAllocs()
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "bytes"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
@@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
159 return history160 return history
160 }161 }
161 162
163+// BenchmarkScreenResizeReflowViewport verifies resize only visits the live
164+// viewport. The 10K-history variant must track the control rather than history
165+// length; history is deliberately populated without changing the viewport.
166+func BenchmarkScreenResizeReflowViewport(b *testing.B) {
167+ b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) })
168+ b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) })
169+}
170+
171+func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
172+ b.Helper()
173+ s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
174+ if historyRows > 0 {
175+ for range historyRows {
176+ s.history.Append(make([]renderer.Cell, 120))
177+ }
178+ }
179+ s.Write(bytes.Repeat([]byte("x"), 120*39))
180+ s.ClearDamage()
181+ sizes := [2][2]int{{80, 40}, {120, 40}}
182+
183+ b.ReportAllocs()
184+ b.ResetTimer()
185+ for i := 0; b.Loop(); i++ {
186+ s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1])
187+ s.ClearDamage()
188+ }
189+ b.StopTimer()
190+ if got := s.History(); historyRows > 0 && got.Len() < historyRows {
191+ b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows)
192+ }
193+}
194+
162 func BenchmarkScreenShellRedrawBurst(b *testing.B) {195 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
163 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")196 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
164 b.ReportAllocs()197 b.ReportAllocs()
A pkg/vt/buffer.go +367 -0
@@ -0,0 +1,367 @@
1+package vt
2+
3+import "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.
8+type buffer struct {
9+ frame renderer.Frame
10+ boundaries []lineBoundary
11+}
12+
13+type lineBoundary struct {
14+ // end is the meaningful cell extent. It excludes padding introduced when a
15+ // wide rune was moved off the right edge.
16+ end int
17+ soft bool // the row continues into the following physical row
18+}
19+
20+func newBuffer(width, height int) *buffer {
21+ return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]lineBoundary, height)}
22+}
23+
24+func bufferFromFrame(frame renderer.Frame) *buffer {
25+ return &buffer{frame: frame, boundaries: make([]lineBoundary, frame.Height)}
26+}
27+
28+func (b *buffer) clone() *buffer {
29+ out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]lineBoundary(nil), b.boundaries...)}
30+ return out
31+}
32+
33+func (b *buffer) content(y, end int) {
34+ if y < 0 || y >= len(b.boundaries) {
35+ return
36+ }
37+ b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
38+}
39+
40+func (b *buffer) truncate(y, end int) {
41+ if y < 0 || y >= len(b.boundaries) {
42+ return
43+ }
44+ b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
45+}
46+
47+// insert retains the meaningful shifted tail when insertion happens within it.
48+func (b *buffer) insert(y, at, width int) {
49+ if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end {
50+ return
51+ }
52+ b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width)
53+}
54+
55+func (b *buffer) hard(y int) {
56+ if y >= 0 && y < len(b.boundaries) {
57+ b.boundaries[y].soft = false
58+ }
59+}
60+
61+func (b *buffer) soft(y int) {
62+ if y >= 0 && y < len(b.boundaries) {
63+ b.boundaries[y].end = b.frame.Width
64+ b.boundaries[y].soft = true
65+ }
66+}
67+
68+func (b *buffer) continueRow(y int) {
69+ if y >= 0 && y < len(b.boundaries) {
70+ b.boundaries[y].soft = true
71+ }
72+}
73+
74+func (b *buffer) clear(y, x0, x1 int) {
75+ if y < 0 || y >= len(b.boundaries) {
76+ return
77+ }
78+ b.content(y, x1)
79+ if x0 == 0 && x1 >= b.frame.Width {
80+ b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft}
81+ }
82+}
83+
84+func (b *buffer) scrollUp(top, bottom, n int) {
85+ // A region operation changes which rows meet at both region edges. A soft
86+ // boundary belongs to the row it follows, not the cells moved into its
87+ // neighbor, so sever links crossing either edge before reflow can observe
88+ // them.
89+ b.hard(top - 1)
90+ copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
91+ for y := bottom - n + 1; y <= bottom; y++ {
92+ b.boundaries[y] = lineBoundary{}
93+ }
94+ b.hard(bottom - n)
95+}
96+
97+func (b *buffer) scrollDown(top, bottom, n int) {
98+ // See scrollUp: the old top no longer follows the row above the region, and
99+ // the last moved row no longer precedes the row below it.
100+ b.hard(top - 1)
101+ copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
102+ for y := top; y < top+n; y++ {
103+ b.boundaries[y] = lineBoundary{}
104+ }
105+ b.hard(bottom)
106+}
107+
108+func (b *buffer) hydrate() {
109+ for y := range b.boundaries {
110+ if b.boundaries[y].end != 0 {
111+ continue
112+ }
113+ for x := b.frame.Width - 1; x >= 0; x-- {
114+ if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
115+ b.boundaries[y].end = x + 1
116+ break
117+ }
118+ }
119+ }
120+}
121+
122+type bufferCursor struct{ row, col int }
123+
124+func (b *buffer) hasSoft() bool {
125+ for _, boundary := range b.boundaries {
126+ if boundary.soft {
127+ return true
128+ }
129+ }
130+ return false
131+}
132+
133+// resizeFixed keeps hard physical lines independent. It is the common shell
134+// path and avoids constructing logical-line scratch state when nothing wraps.
135+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
136+ anchor := 0
137+ if active != nil {
138+ anchor = active.row
139+ }
140+ shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
141+ evicted := make([][]renderer.Cell, 0, shift)
142+ for y := range shift {
143+ evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
144+ }
145+ // Fixed-line resizes can retain the boundary backing store. In particular,
146+ // keep its capacity through a short viewport so the common shrink/grow
147+ // sequence does not add metadata allocation to every resize epoch.
148+ boundaries := b.boundaries
149+ if cap(boundaries) < height {
150+ boundaries = make([]lineBoundary, height)
151+ } else {
152+ boundaries = boundaries[:height]
153+ }
154+ next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries}
155+ copied := 0
156+ for y := range height {
157+ sy := y + shift
158+ if sy >= b.frame.Height {
159+ break
160+ }
161+ copy(next.frame.Row(y), b.frame.Row(sy))
162+ next.boundaries[y] = b.boundaries[sy]
163+ next.boundaries[y].end = min(next.boundaries[y].end, width)
164+ repairFrameRow(next.frame, y)
165+ copied++
166+ }
167+ clear(next.boundaries[copied:])
168+ for _, cur := range [2]*bufferCursor{active, saved} {
169+ if cur != nil {
170+ cur.row = clamp(cur.row-shift, 0, height-1)
171+ cur.col = clamp(cur.col, 0, width)
172+ }
173+ }
174+ *b = next
175+ return evicted
176+}
177+
178+type reflowPoint struct {
179+ line, offset int
180+ row, col int
181+}
182+
183+// cursorReflowPoints maps source cursor positions to offsets in their logical
184+// lines. There are exactly two callers (active and DECSC), so this stays flat
185+// and stack allocated rather than building a row/column lookup map.
186+func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint {
187+ points := [2]reflowPoint{{line: -1}, {line: -1}}
188+ cursors := [2]*bufferCursor{active, saved}
189+ for start := 0; start < b.frame.Height; {
190+ end := start
191+ for b.boundaries[end].soft && end+1 < b.frame.Height {
192+ end++
193+ }
194+ for i, cur := range cursors {
195+ if cur == nil || cur.row < start || cur.row > end {
196+ continue
197+ }
198+ offset := 0
199+ for y := start; y < cur.row; y++ {
200+ offset += b.boundaries[y].end
201+ }
202+ points[i] = reflowPoint{
203+ line: start,
204+ offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end),
205+ }
206+ }
207+ start = end + 1
208+ }
209+ return points
210+}
211+
212+// resize lays out only the current grid. It does two direct, bounded passes:
213+// the first maps both cursors and counts rows; the second writes only the
214+// retained viewport (and the rows genuinely evicted to history). It never
215+// materializes logical lines, per-cell position maps, or temporary output rows.
216+func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
217+ if !b.hasSoft() {
218+ return b.resizeFixed(width, height, active, saved)
219+ }
220+ // Only reflow needs meaningful extents; hard physical rows are copied as
221+ // cells, so scanning blank rows on the shell fast path is wasted work.
222+ b.hydrate()
223+ for _, cur := range [2]*bufferCursor{active, saved} {
224+ if cur != nil {
225+ b.content(cur.row, cur.col)
226+ }
227+ }
228+
229+ points := b.cursorReflowPoints(active, saved)
230+ rows := b.layoutReflow(width, &points, nil, 0, nil)
231+ anchor := 0
232+ if active != nil {
233+ anchor = points[0].row
234+ }
235+ shift := clamp(anchor-(height-1), 0, max(rows-height, 0))
236+
237+ next := newBuffer(width, height)
238+ var evicted [][]renderer.Cell
239+ var evictedCells []renderer.Cell
240+ if shift > 0 {
241+ // History needs owned rows. One flat backing store replaces a temporary
242+ // allocation per evicted output row.
243+ evicted = make([][]renderer.Cell, shift)
244+ evictedCells = make([]renderer.Cell, shift*width)
245+ for y := range evicted {
246+ evicted[y] = evictedCells[y*width : (y+1)*width]
247+ }
248+ }
249+ b.layoutReflow(width, &points, next, shift, evictedCells)
250+ for i, cur := range [2]*bufferCursor{active, saved} {
251+ if cur != nil {
252+ cur.row = clamp(points[i].row-shift, 0, height-1)
253+ cur.col = clamp(points[i].col, 0, width)
254+ }
255+ }
256+ *b = *next
257+ return evicted
258+}
259+
260+// layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
261+// [shift, shift+dst.height). Rows before shift are written straight into the
262+// contiguous eviction backing store. The return is the logical output height.
263+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int {
264+ row, col := 0, 0
265+ blankEvicted := func(outputRow int) []renderer.Cell {
266+ if outputRow < shift {
267+ out := evicted[outputRow*width : (outputRow+1)*width]
268+ for i := range out {
269+ out[i] = renderer.BlankCell()
270+ }
271+ return out
272+ }
273+ if dst != nil && outputRow < shift+dst.frame.Height {
274+ return dst.frame.Row(outputRow - shift)
275+ }
276+ return nil
277+ }
278+ var output []renderer.Cell
279+ if dst != nil && (shift > 0 || dst.frame.Height > 0) {
280+ output = blankEvicted(0)
281+ }
282+ finishRow := func(soft bool) {
283+ if dst != nil && row >= shift && row < shift+dst.frame.Height {
284+ dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft}
285+ }
286+ row++
287+ col = 0
288+ if dst != nil {
289+ output = blankEvicted(row)
290+ }
291+ }
292+ setPoint := func(line, offset, pointRow, pointCol int) {
293+ for i := range points {
294+ if points[i].line == line && points[i].offset == offset {
295+ points[i].row, points[i].col = pointRow, pointCol
296+ }
297+ }
298+ }
299+ setRemainingPoints := func(line, offset, pointRow, pointCol int) {
300+ for i := range points {
301+ if points[i].line == line && points[i].offset >= offset {
302+ points[i].row, points[i].col = pointRow, pointCol
303+ }
304+ }
305+ }
306+
307+ for start := 0; start < b.frame.Height; {
308+ end := start
309+ for b.boundaries[end].soft && end+1 < b.frame.Height {
310+ end++
311+ }
312+ reflow := end > start
313+ offset := 0
314+ truncated := false
315+ for y := start; y <= end && !truncated; y++ {
316+ cells := b.frame.Row(y)
317+ limit := b.boundaries[y].end
318+ for x := 0; x < limit; {
319+ cell := cells[x]
320+ if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
321+ setPoint(start, offset, row, col)
322+ x++
323+ offset++
324+ continue
325+ }
326+ wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation
327+ w := 1
328+ if wide && width >= 2 {
329+ w = 2
330+ }
331+ if col+w > width && col > 0 {
332+ if !reflow {
333+ setRemainingPoints(start, offset, row, col)
334+ truncated = true
335+ break
336+ }
337+ finishRow(true)
338+ }
339+ setPoint(start, offset, row, col)
340+ if wide && width >= 2 {
341+ if output != nil {
342+ output[col], output[col+1] = cell, cells[x+1]
343+ }
344+ setPoint(start, offset+1, row, col+1)
345+ col += 2
346+ x += 2
347+ offset += 2
348+ continue
349+ }
350+ if wide {
351+ cell.Rune = '\uFFFD'
352+ cell.Continuation = false
353+ }
354+ if output != nil {
355+ output[col] = cell
356+ }
357+ col++
358+ x++
359+ offset++
360+ }
361+ }
362+ setPoint(start, offset, row, col)
363+ finishRow(false)
364+ start = end + 1
365+ }
366+ return row
367+}
@@ -0,0 +1,367 @@
1+package vt
2+
3+import "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.
8+type buffer struct {
9+ frame renderer.Frame
10+ boundaries []lineBoundary
11+}
12+
13+type lineBoundary struct {
14+ // end is the meaningful cell extent. It excludes padding introduced when a
15+ // wide rune was moved off the right edge.
16+ end int
17+ soft bool // the row continues into the following physical row
18+}
19+
20+func newBuffer(width, height int) *buffer {
21+ return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]lineBoundary, height)}
22+}
23+
24+func bufferFromFrame(frame renderer.Frame) *buffer {
25+ return &buffer{frame: frame, boundaries: make([]lineBoundary, frame.Height)}
26+}
27+
28+func (b *buffer) clone() *buffer {
29+ out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]lineBoundary(nil), b.boundaries...)}
30+ return out
31+}
32+
33+func (b *buffer) content(y, end int) {
34+ if y < 0 || y >= len(b.boundaries) {
35+ return
36+ }
37+ b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
38+}
39+
40+func (b *buffer) truncate(y, end int) {
41+ if y < 0 || y >= len(b.boundaries) {
42+ return
43+ }
44+ b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
45+}
46+
47+// insert retains the meaningful shifted tail when insertion happens within it.
48+func (b *buffer) insert(y, at, width int) {
49+ if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end {
50+ return
51+ }
52+ b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width)
53+}
54+
55+func (b *buffer) hard(y int) {
56+ if y >= 0 && y < len(b.boundaries) {
57+ b.boundaries[y].soft = false
58+ }
59+}
60+
61+func (b *buffer) soft(y int) {
62+ if y >= 0 && y < len(b.boundaries) {
63+ b.boundaries[y].end = b.frame.Width
64+ b.boundaries[y].soft = true
65+ }
66+}
67+
68+func (b *buffer) continueRow(y int) {
69+ if y >= 0 && y < len(b.boundaries) {
70+ b.boundaries[y].soft = true
71+ }
72+}
73+
74+func (b *buffer) clear(y, x0, x1 int) {
75+ if y < 0 || y >= len(b.boundaries) {
76+ return
77+ }
78+ b.content(y, x1)
79+ if x0 == 0 && x1 >= b.frame.Width {
80+ b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft}
81+ }
82+}
83+
84+func (b *buffer) scrollUp(top, bottom, n int) {
85+ // A region operation changes which rows meet at both region edges. A soft
86+ // boundary belongs to the row it follows, not the cells moved into its
87+ // neighbor, so sever links crossing either edge before reflow can observe
88+ // them.
89+ b.hard(top - 1)
90+ copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
91+ for y := bottom - n + 1; y <= bottom; y++ {
92+ b.boundaries[y] = lineBoundary{}
93+ }
94+ b.hard(bottom - n)
95+}
96+
97+func (b *buffer) scrollDown(top, bottom, n int) {
98+ // See scrollUp: the old top no longer follows the row above the region, and
99+ // the last moved row no longer precedes the row below it.
100+ b.hard(top - 1)
101+ copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
102+ for y := top; y < top+n; y++ {
103+ b.boundaries[y] = lineBoundary{}
104+ }
105+ b.hard(bottom)
106+}
107+
108+func (b *buffer) hydrate() {
109+ for y := range b.boundaries {
110+ if b.boundaries[y].end != 0 {
111+ continue
112+ }
113+ for x := b.frame.Width - 1; x >= 0; x-- {
114+ if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
115+ b.boundaries[y].end = x + 1
116+ break
117+ }
118+ }
119+ }
120+}
121+
122+type bufferCursor struct{ row, col int }
123+
124+func (b *buffer) hasSoft() bool {
125+ for _, boundary := range b.boundaries {
126+ if boundary.soft {
127+ return true
128+ }
129+ }
130+ return false
131+}
132+
133+// resizeFixed keeps hard physical lines independent. It is the common shell
134+// path and avoids constructing logical-line scratch state when nothing wraps.
135+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
136+ anchor := 0
137+ if active != nil {
138+ anchor = active.row
139+ }
140+ shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
141+ evicted := make([][]renderer.Cell, 0, shift)
142+ for y := range shift {
143+ evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
144+ }
145+ // Fixed-line resizes can retain the boundary backing store. In particular,
146+ // keep its capacity through a short viewport so the common shrink/grow
147+ // sequence does not add metadata allocation to every resize epoch.
148+ boundaries := b.boundaries
149+ if cap(boundaries) < height {
150+ boundaries = make([]lineBoundary, height)
151+ } else {
152+ boundaries = boundaries[:height]
153+ }
154+ next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries}
155+ copied := 0
156+ for y := range height {
157+ sy := y + shift
158+ if sy >= b.frame.Height {
159+ break
160+ }
161+ copy(next.frame.Row(y), b.frame.Row(sy))
162+ next.boundaries[y] = b.boundaries[sy]
163+ next.boundaries[y].end = min(next.boundaries[y].end, width)
164+ repairFrameRow(next.frame, y)
165+ copied++
166+ }
167+ clear(next.boundaries[copied:])
168+ for _, cur := range [2]*bufferCursor{active, saved} {
169+ if cur != nil {
170+ cur.row = clamp(cur.row-shift, 0, height-1)
171+ cur.col = clamp(cur.col, 0, width)
172+ }
173+ }
174+ *b = next
175+ return evicted
176+}
177+
178+type reflowPoint struct {
179+ line, offset int
180+ row, col int
181+}
182+
183+// cursorReflowPoints maps source cursor positions to offsets in their logical
184+// lines. There are exactly two callers (active and DECSC), so this stays flat
185+// and stack allocated rather than building a row/column lookup map.
186+func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint {
187+ points := [2]reflowPoint{{line: -1}, {line: -1}}
188+ cursors := [2]*bufferCursor{active, saved}
189+ for start := 0; start < b.frame.Height; {
190+ end := start
191+ for b.boundaries[end].soft && end+1 < b.frame.Height {
192+ end++
193+ }
194+ for i, cur := range cursors {
195+ if cur == nil || cur.row < start || cur.row > end {
196+ continue
197+ }
198+ offset := 0
199+ for y := start; y < cur.row; y++ {
200+ offset += b.boundaries[y].end
201+ }
202+ points[i] = reflowPoint{
203+ line: start,
204+ offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end),
205+ }
206+ }
207+ start = end + 1
208+ }
209+ return points
210+}
211+
212+// resize lays out only the current grid. It does two direct, bounded passes:
213+// the first maps both cursors and counts rows; the second writes only the
214+// retained viewport (and the rows genuinely evicted to history). It never
215+// materializes logical lines, per-cell position maps, or temporary output rows.
216+func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell {
217+ if !b.hasSoft() {
218+ return b.resizeFixed(width, height, active, saved)
219+ }
220+ // Only reflow needs meaningful extents; hard physical rows are copied as
221+ // cells, so scanning blank rows on the shell fast path is wasted work.
222+ b.hydrate()
223+ for _, cur := range [2]*bufferCursor{active, saved} {
224+ if cur != nil {
225+ b.content(cur.row, cur.col)
226+ }
227+ }
228+
229+ points := b.cursorReflowPoints(active, saved)
230+ rows := b.layoutReflow(width, &points, nil, 0, nil)
231+ anchor := 0
232+ if active != nil {
233+ anchor = points[0].row
234+ }
235+ shift := clamp(anchor-(height-1), 0, max(rows-height, 0))
236+
237+ next := newBuffer(width, height)
238+ var evicted [][]renderer.Cell
239+ var evictedCells []renderer.Cell
240+ if shift > 0 {
241+ // History needs owned rows. One flat backing store replaces a temporary
242+ // allocation per evicted output row.
243+ evicted = make([][]renderer.Cell, shift)
244+ evictedCells = make([]renderer.Cell, shift*width)
245+ for y := range evicted {
246+ evicted[y] = evictedCells[y*width : (y+1)*width]
247+ }
248+ }
249+ b.layoutReflow(width, &points, next, shift, evictedCells)
250+ for i, cur := range [2]*bufferCursor{active, saved} {
251+ if cur != nil {
252+ cur.row = clamp(points[i].row-shift, 0, height-1)
253+ cur.col = clamp(points[i].col, 0, width)
254+ }
255+ }
256+ *b = *next
257+ return evicted
258+}
259+
260+// layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
261+// [shift, shift+dst.height). Rows before shift are written straight into the
262+// contiguous eviction backing store. The return is the logical output height.
263+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int {
264+ row, col := 0, 0
265+ blankEvicted := func(outputRow int) []renderer.Cell {
266+ if outputRow < shift {
267+ out := evicted[outputRow*width : (outputRow+1)*width]
268+ for i := range out {
269+ out[i] = renderer.BlankCell()
270+ }
271+ return out
272+ }
273+ if dst != nil && outputRow < shift+dst.frame.Height {
274+ return dst.frame.Row(outputRow - shift)
275+ }
276+ return nil
277+ }
278+ var output []renderer.Cell
279+ if dst != nil && (shift > 0 || dst.frame.Height > 0) {
280+ output = blankEvicted(0)
281+ }
282+ finishRow := func(soft bool) {
283+ if dst != nil && row >= shift && row < shift+dst.frame.Height {
284+ dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft}
285+ }
286+ row++
287+ col = 0
288+ if dst != nil {
289+ output = blankEvicted(row)
290+ }
291+ }
292+ setPoint := func(line, offset, pointRow, pointCol int) {
293+ for i := range points {
294+ if points[i].line == line && points[i].offset == offset {
295+ points[i].row, points[i].col = pointRow, pointCol
296+ }
297+ }
298+ }
299+ setRemainingPoints := func(line, offset, pointRow, pointCol int) {
300+ for i := range points {
301+ if points[i].line == line && points[i].offset >= offset {
302+ points[i].row, points[i].col = pointRow, pointCol
303+ }
304+ }
305+ }
306+
307+ for start := 0; start < b.frame.Height; {
308+ end := start
309+ for b.boundaries[end].soft && end+1 < b.frame.Height {
310+ end++
311+ }
312+ reflow := end > start
313+ offset := 0
314+ truncated := false
315+ for y := start; y <= end && !truncated; y++ {
316+ cells := b.frame.Row(y)
317+ limit := b.boundaries[y].end
318+ for x := 0; x < limit; {
319+ cell := cells[x]
320+ if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
321+ setPoint(start, offset, row, col)
322+ x++
323+ offset++
324+ continue
325+ }
326+ wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation
327+ w := 1
328+ if wide && width >= 2 {
329+ w = 2
330+ }
331+ if col+w > width && col > 0 {
332+ if !reflow {
333+ setRemainingPoints(start, offset, row, col)
334+ truncated = true
335+ break
336+ }
337+ finishRow(true)
338+ }
339+ setPoint(start, offset, row, col)
340+ if wide && width >= 2 {
341+ if output != nil {
342+ output[col], output[col+1] = cell, cells[x+1]
343+ }
344+ setPoint(start, offset+1, row, col+1)
345+ col += 2
346+ x += 2
347+ offset += 2
348+ continue
349+ }
350+ if wide {
351+ cell.Rune = '\uFFFD'
352+ cell.Continuation = false
353+ }
354+ if output != nil {
355+ output[col] = cell
356+ }
357+ col++
358+ x++
359+ offset++
360+ }
361+ }
362+ setPoint(start, offset, row, col)
363+ finishRow(false)
364+ start = end + 1
365+ }
366+ return row
367+}
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/history_codec.go +3 -2
@@ -24,8 +24,9 @@ const (
2424 maxHistoryRowCells = 160
2525 maxHistoryCells = maxHistoryRows * maxHistoryRowCells
2626
27- maxHistoryDecodeStyles = maxHistoryCells
28- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
27+ maxHistoryDecodeStyles = maxHistoryCells
28+ maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
29+ maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes
2930 )
3031
3132 var errInvalidHistory = errors.New("invalid history payload")
@@ -24,8 +24,9 @@ const (
24 maxHistoryRowCells = 16024 maxHistoryRowCells = 160
25 maxHistoryCells = maxHistoryRows * maxHistoryRowCells25 maxHistoryCells = maxHistoryRows * maxHistoryRowCells
26 26
27- maxHistoryDecodeStyles = maxHistoryCells27+ maxHistoryDecodeStyles = maxHistoryCells
28- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes28+ maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
29+ maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes
29 )30 )
30 31
31 var errInvalidHistory = errors.New("invalid history payload")32 var errInvalidHistory = errors.New("invalid history payload")
A pkg/vt/region_reflow_test.go +52 -0
@@ -0,0 +1,52 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ seq string
13+ want []string
14+ }{
15+ {
16+ name: "CSI S",
17+ seq: "\x1b[2;4r\x1b[S",
18+ want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"},
19+ },
20+ {
21+ name: "CSI T",
22+ seq: "\x1b[2;4r\x1b[T",
23+ want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"},
24+ },
25+ {
26+ name: "insert lines",
27+ seq: "\x1b[2;4r\x1b[2;1H\x1b[L",
28+ want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"},
29+ },
30+ {
31+ name: "delete lines",
32+ seq: "\x1b[2;4r\x1b[2;1H\x1b[M",
33+ want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"},
34+ },
35+ }
36+
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ s := NewScreen(4, 5)
40+ s.Write([]byte("ABCDEFGHIJKLMNOPQRST"))
41+ s.Write([]byte(tt.seq))
42+
43+ s.Resize(3, 6)
44+
45+ got := make([]string, s.Frame.Height)
46+ for y := range s.Frame.Height {
47+ got[y] = rowString(s.Frame.Row(y))
48+ }
49+ require.Equal(t, tt.want, got)
50+ })
51+ }
52+}
@@ -0,0 +1,52 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ seq string
13+ want []string
14+ }{
15+ {
16+ name: "CSI S",
17+ seq: "\x1b[2;4r\x1b[S",
18+ want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"},
19+ },
20+ {
21+ name: "CSI T",
22+ seq: "\x1b[2;4r\x1b[T",
23+ want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"},
24+ },
25+ {
26+ name: "insert lines",
27+ seq: "\x1b[2;4r\x1b[2;1H\x1b[L",
28+ want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"},
29+ },
30+ {
31+ name: "delete lines",
32+ seq: "\x1b[2;4r\x1b[2;1H\x1b[M",
33+ want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"},
34+ },
35+ }
36+
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ s := NewScreen(4, 5)
40+ s.Write([]byte("ABCDEFGHIJKLMNOPQRST"))
41+ s.Write([]byte(tt.seq))
42+
43+ s.Resize(3, 6)
44+
45+ got := make([]string, s.Frame.Height)
46+ for y := range s.Frame.Height {
47+ got[y] = rowString(s.Frame.Row(y))
48+ }
49+ require.Equal(t, tt.want, got)
50+ })
51+ }
52+}
A pkg/vt/resize_reflow_regression_test.go +82 -0
@@ -0,0 +1,82 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
11+ s := NewScreen(5, 3)
12+ s.Write([]byte("shell"))
13+ s.Write([]byte("\x1b[?1049h"))
14+ s.Write([]byte("\x1b["))
15+
16+ s.Resize(0, 0)
17+
18+ if len(s.escapeBuf) != 0 {
19+ t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
20+ }
21+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
22+ t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
23+ }
24+ s.Write([]byte("\x1b[?1049l"))
25+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
26+ t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
27+ }
28+}
29+
30+func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) {
31+ s := NewScreen(4, 3)
32+ s.Write([]byte("ab界"))
33+ s.Write([]byte("\x1b[1;4H界x"))
34+
35+ s.Resize(6, 3)
36+
37+ assertCell(t, s, 0, 0, 'a')
38+ assertCell(t, s, 1, 0, 'b')
39+ assertCell(t, s, 2, 0, '界')
40+ if !cellAt(s, 3, 0).Continuation {
41+ t.Fatal("reflowed wide rune lost continuation")
42+ }
43+ assertCell(t, s, 4, 0, 'x')
44+}
45+
46+func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) {
47+ s := NewScreen(6, 2)
48+ s.Write([]byte("abcd"))
49+ s.Write([]byte("\x1b[1;2H\x1b[4hX"))
50+ // Simulate the subsequent logical continuation that causes the row to be
51+ // reflowed. Its meaningful extent must include the shifted tail.
52+ s.buffer.continueRow(0)
53+
54+ s.Resize(5, 2)
55+
56+ for x, r := range []rune("aXbcd") {
57+ assertCell(t, s, x, 0, r)
58+ }
59+}
60+
61+func TestVisibleBoundaryMetadataBudget(t *testing.T) {
62+ const height = maxHistoryRows + 1
63+ if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil {
64+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
65+ }
66+ s := NewScreen(0, height)
67+ if _, err := s.MarshalPrimaryVisible(); err == nil {
68+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
69+ }
70+
71+ data := make([]byte, 13+height*visibleBoundaryBytes)
72+ copy(data[:4], visibleMagic)
73+ data[4] = historyVersion
74+ binary.BigEndian.PutUint32(data[9:13], height)
75+ if _, err := PreflightVisibleBlob(data); err == nil {
76+ t.Fatal("preflight accepted boundary metadata beyond its allocation budget")
77+ }
78+ if _, err := UnmarshalVisible(data); err == nil {
79+ t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget")
80+ }
81+
82+}
@@ -0,0 +1,82 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
11+ s := NewScreen(5, 3)
12+ s.Write([]byte("shell"))
13+ s.Write([]byte("\x1b[?1049h"))
14+ s.Write([]byte("\x1b["))
15+
16+ s.Resize(0, 0)
17+
18+ if len(s.escapeBuf) != 0 {
19+ t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
20+ }
21+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
22+ t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
23+ }
24+ s.Write([]byte("\x1b[?1049l"))
25+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
26+ t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
27+ }
28+}
29+
30+func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) {
31+ s := NewScreen(4, 3)
32+ s.Write([]byte("ab界"))
33+ s.Write([]byte("\x1b[1;4H界x"))
34+
35+ s.Resize(6, 3)
36+
37+ assertCell(t, s, 0, 0, 'a')
38+ assertCell(t, s, 1, 0, 'b')
39+ assertCell(t, s, 2, 0, '界')
40+ if !cellAt(s, 3, 0).Continuation {
41+ t.Fatal("reflowed wide rune lost continuation")
42+ }
43+ assertCell(t, s, 4, 0, 'x')
44+}
45+
46+func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) {
47+ s := NewScreen(6, 2)
48+ s.Write([]byte("abcd"))
49+ s.Write([]byte("\x1b[1;2H\x1b[4hX"))
50+ // Simulate the subsequent logical continuation that causes the row to be
51+ // reflowed. Its meaningful extent must include the shifted tail.
52+ s.buffer.continueRow(0)
53+
54+ s.Resize(5, 2)
55+
56+ for x, r := range []rune("aXbcd") {
57+ assertCell(t, s, x, 0, r)
58+ }
59+}
60+
61+func TestVisibleBoundaryMetadataBudget(t *testing.T) {
62+ const height = maxHistoryRows + 1
63+ if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil {
64+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
65+ }
66+ s := NewScreen(0, height)
67+ if _, err := s.MarshalPrimaryVisible(); err == nil {
68+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
69+ }
70+
71+ data := make([]byte, 13+height*visibleBoundaryBytes)
72+ copy(data[:4], visibleMagic)
73+ data[4] = historyVersion
74+ binary.BigEndian.PutUint32(data[9:13], height)
75+ if _, err := PreflightVisibleBlob(data); err == nil {
76+ t.Fatal("preflight accepted boundary metadata beyond its allocation budget")
77+ }
78+ if _, err := UnmarshalVisible(data); err == nil {
79+ t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget")
80+ }
81+
82+}
M pkg/vt/screen.go +5 -1
@@ -51,6 +51,7 @@ type Screen struct {
5151 OnClipboard func(b64 string)
5252
5353 history *History
54+ buffer *buffer
5455
5556 defaultFG renderer.RGB
5657 defaultBG renderer.RGB
@@ -85,8 +86,10 @@ type Screen struct {
8586 }
8687
8788 func NewScreen(width, height int) *Screen {
89+ b := newBuffer(width, height)
8890 s := &Screen{
89- Frame: renderer.NewFrame(width, height),
91+ Frame: b.frame,
92+ buffer: b,
9093 Style: renderer.DefaultStyle(),
9194 damage: []renderer.Damage{renderer.FullRedraw()},
9295 damageGeneration: 1,
@@ -165,6 +168,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
165168 s.restoreCursor()
166169 return 2, false
167170 case 'D':
171+ s.buffer.hard(s.Row)
168172 s.index()
169173 return 2, false
170174 case 'E':
@@ -51,6 +51,7 @@ type Screen struct {
51 OnClipboard func(b64 string)51 OnClipboard func(b64 string)
52 52
53 history *History53 history *History
54+ buffer *buffer
54 55
55 defaultFG renderer.RGB56 defaultFG renderer.RGB
56 defaultBG renderer.RGB57 defaultBG renderer.RGB
@@ -85,8 +86,10 @@ type Screen struct {
85 }86 }
86 87
87 func NewScreen(width, height int) *Screen {88 func NewScreen(width, height int) *Screen {
89+ b := newBuffer(width, height)
88 s := &Screen{90 s := &Screen{
89- Frame: renderer.NewFrame(width, height),91+ Frame: b.frame,
92+ buffer: b,
90 Style: renderer.DefaultStyle(),93 Style: renderer.DefaultStyle(),
91 damage: []renderer.Damage{renderer.FullRedraw()},94 damage: []renderer.Damage{renderer.FullRedraw()},
92 damageGeneration: 1,95 damageGeneration: 1,
@@ -165,6 +168,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
165 s.restoreCursor()168 s.restoreCursor()
166 return 2, false169 return 2, false
167 case 'D':170 case 'D':
171+ s.buffer.hard(s.Row)
168 s.index()172 s.index()
169 return 2, false173 return 2, false
170 case 'E':174 case 'E':
M pkg/vt/screen_csi.go +3 -0
@@ -408,6 +408,9 @@ func (s *Screen) clearScreenMode(mode int) {
408408 for i := range s.Frame.Cells {
409409 s.Frame.Cells[i] = blank
410410 }
411+ for y := range s.buffer.boundaries {
412+ s.buffer.boundaries[y] = lineBoundary{soft: s.buffer.boundaries[y].soft}
413+ }
411414 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
412415 default:
413416 for y := s.Row; y < s.Frame.Height; y++ {
@@ -408,6 +408,9 @@ func (s *Screen) clearScreenMode(mode int) {
408 for i := range s.Frame.Cells {408 for i := range s.Frame.Cells {
409 s.Frame.Cells[i] = blank409 s.Frame.Cells[i] = blank
410 }410 }
411+ for y := range s.buffer.boundaries {
412+ s.buffer.boundaries[y] = lineBoundary{soft: s.buffer.boundaries[y].soft}
413+ }
411 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})414 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
412 default:415 default:
413 for y := s.Row; y < s.Frame.Height; y++ {416 for y := s.Row; y < s.Frame.Height; y++ {
M pkg/vt/screen_modes.go +11 -3
@@ -4,6 +4,7 @@ import "github.com/bnema/vev/pkg/renderer"
44
55 type screenState struct {
66 frame renderer.Frame
7+ buffer *buffer
78 row int
89 col int
910 style renderer.Style
@@ -86,7 +87,8 @@ func (s *Screen) colorSchemeReport() []byte {
8687 }
8788
8889 func (s *Screen) reset() {
89- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
90+ s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)
91+ s.Frame = s.buffer.frame
9092 s.Row, s.Col = 0, 0
9193 s.Style = renderer.DefaultStyle()
9294 s.escapeBuf = s.escapeBuf[:0]
@@ -190,6 +192,7 @@ func (s *Screen) enterAlternateScreen() {
190192 if s.alternate == nil {
191193 s.alternate = &screenState{
192194 frame: cloneFrame(s.Frame),
195+ buffer: s.buffer.clone(),
193196 row: s.Row,
194197 col: s.Col,
195198 style: s.Style,
@@ -200,7 +203,8 @@ func (s *Screen) enterAlternateScreen() {
200203 insertMode: s.insertMode,
201204 }
202205 }
203- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
206+ s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)
207+ s.Frame = s.buffer.frame
204208 s.Row, s.Col = 0, 0
205209 s.Style = renderer.DefaultStyle()
206210 s.savedCursor = cursorState{}
@@ -213,7 +217,11 @@ func (s *Screen) exitAlternateScreen() {
213217 return
214218 }
215219 state := s.alternate
216- s.Frame = cloneFrame(state.frame)
220+ s.buffer = state.buffer
221+ if s.buffer == nil {
222+ s.buffer = bufferFromFrame(cloneFrame(state.frame))
223+ }
224+ s.Frame = s.buffer.frame
217225 s.Row = clamp(state.row, 0, s.Frame.Height-1)
218226 s.Col = clamp(state.col, 0, s.Frame.Width-1)
219227 s.Style = state.style
@@ -4,6 +4,7 @@ import "github.com/bnema/vev/pkg/renderer"
4 4
5 type screenState struct {5 type screenState struct {
6 frame renderer.Frame6 frame renderer.Frame
7+ buffer *buffer
7 row int8 row int
8 col int9 col int
9 style renderer.Style10 style renderer.Style
@@ -86,7 +87,8 @@ func (s *Screen) colorSchemeReport() []byte {
86 }87 }
87 88
88 func (s *Screen) reset() {89 func (s *Screen) reset() {
89- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)90+ s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)
91+ s.Frame = s.buffer.frame
90 s.Row, s.Col = 0, 092 s.Row, s.Col = 0, 0
91 s.Style = renderer.DefaultStyle()93 s.Style = renderer.DefaultStyle()
92 s.escapeBuf = s.escapeBuf[:0]94 s.escapeBuf = s.escapeBuf[:0]
@@ -190,6 +192,7 @@ func (s *Screen) enterAlternateScreen() {
190 if s.alternate == nil {192 if s.alternate == nil {
191 s.alternate = &screenState{193 s.alternate = &screenState{
192 frame: cloneFrame(s.Frame),194 frame: cloneFrame(s.Frame),
195+ buffer: s.buffer.clone(),
193 row: s.Row,196 row: s.Row,
194 col: s.Col,197 col: s.Col,
195 style: s.Style,198 style: s.Style,
@@ -200,7 +203,8 @@ func (s *Screen) enterAlternateScreen() {
200 insertMode: s.insertMode,203 insertMode: s.insertMode,
201 }204 }
202 }205 }
203- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)206+ s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)
207+ s.Frame = s.buffer.frame
204 s.Row, s.Col = 0, 0208 s.Row, s.Col = 0, 0
205 s.Style = renderer.DefaultStyle()209 s.Style = renderer.DefaultStyle()
206 s.savedCursor = cursorState{}210 s.savedCursor = cursorState{}
@@ -213,7 +217,11 @@ func (s *Screen) exitAlternateScreen() {
213 return217 return
214 }218 }
215 state := s.alternate219 state := s.alternate
216- s.Frame = cloneFrame(state.frame)220+ s.buffer = state.buffer
221+ if s.buffer == nil {
222+ s.buffer = bufferFromFrame(cloneFrame(state.frame))
223+ }
224+ s.Frame = s.buffer.frame
217 s.Row = clamp(state.row, 0, s.Frame.Height-1)225 s.Row = clamp(state.row, 0, s.Frame.Height-1)
218 s.Col = clamp(state.col, 0, s.Frame.Width-1)226 s.Col = clamp(state.col, 0, s.Frame.Width-1)
219 s.Style = state.style227 s.Style = state.style
M pkg/vt/screen_resize.go +44 -51
@@ -6,32 +6,53 @@ func (s *Screen) Resize(width, height int) {
66 if width == s.Frame.Width && height == s.Frame.Height {
77 return
88 }
9- evict := s.recordEvicted
10-
11- if s.alternate != nil {
12- var shift int
13- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
14- s.Row = clamp(s.Row-shift, 0, height-1)
15- s.Col = clamp(s.Col, 0, width-1)
16- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
17- s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
18- s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
19- s.alternate.col = clamp(s.alternate.col, 0, width-1)
20- s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
21- s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
22- s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
23- if s.alternate.scrollTop >= s.alternate.scrollBottom {
24- s.alternate.scrollTop = 0
25- s.alternate.scrollBottom = max(height-1, 0)
9+ if width <= 0 || height <= 0 {
10+ // renderer.Frame permits a collapsed geometry. There is no layout to
11+ // preserve until a usable viewport is supplied again.
12+ width, height = max(width, 0), max(height, 0)
13+ s.buffer = newBuffer(width, height)
14+ s.Row, s.Col = 0, 0
15+ if state := s.alternate; state != nil {
16+ state.buffer = newBuffer(width, height)
17+ state.frame = state.buffer.frame
18+ state.row, state.col = 0, 0
19+ state.scrollTop, state.scrollBottom = 0, height-1
2620 }
2721 } else {
28- var shift int
29- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
30- s.Row = clamp(s.Row-shift, 0, height-1)
31- s.Col = clamp(s.Col, 0, width-1)
32- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
33- }
22+ resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
23+ active := &bufferCursor{row: row, col: col}
24+ var savedPoint *bufferCursor
25+ if saved != nil && saved.saved {
26+ savedPoint = &bufferCursor{row: saved.row, col: saved.col}
27+ }
28+ evicted := b.resize(width, height, active, savedPoint)
29+ if evict {
30+ for _, line := range evicted {
31+ s.recordEvicted(line)
32+ }
33+ }
34+ if savedPoint != nil {
35+ saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
36+ }
37+ return active.row, clamp(active.col, 0, width-1)
38+ }
3439
40+ if s.alternate != nil {
41+ // Both screen states are reflowed independently, but active and saved
42+ // cursors for each state are mapped by their single buffer layout pass.
43+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
44+ state := s.alternate
45+ if state.buffer == nil {
46+ state.buffer = bufferFromFrame(state.frame)
47+ }
48+ state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
49+ state.frame = state.buffer.frame
50+ state.scrollTop, state.scrollBottom = 0, height-1
51+ } else {
52+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
53+ }
54+ }
55+ s.Frame = s.buffer.frame
3556 // A resize can split an in-flight escape sequence from the terminal state it
3657 // was meant to mutate; keep the durable child state but discard partial bytes.
3758 s.escapeBuf = s.escapeBuf[:0]
@@ -39,34 +60,6 @@ func (s *Screen) Resize(width, height int) {
3960 s.fullRedraw()
4061 }
4162
42-func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
43- next := renderer.NewFrame(newW, newH)
44- shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
45- if evict != nil {
46- for y := range shift {
47- evict(old.Row(y))
48- }
49- }
50- for dy := range newH {
51- sy := dy + shift
52- if sy >= old.Height {
53- break
54- }
55- copy(next.Row(dy), old.Row(sy))
56- repairFrameRow(next, dy)
57- }
58- return next, shift
59-}
60-
61-func resizedCursor(cur cursorState, shift, width, height int) cursorState {
62- if !cur.saved {
63- return cur
64- }
65- cur.row = clamp(cur.row-shift, 0, height-1)
66- cur.col = clamp(cur.col, 0, width-1)
67- return cur
68-}
69-
7063 // cloneFrame produces an independent copy in canonical layout: it copies the
7164 // source's logical rows (via Row) into a fresh frame whose offsets are already
7265 // canonical, so a rotated source is normalized in the clone.
@@ -6,32 +6,53 @@ func (s *Screen) Resize(width, height int) {
6 if width == s.Frame.Width && height == s.Frame.Height {6 if width == s.Frame.Width && height == s.Frame.Height {
7 return7 return
8 }8 }
9- evict := s.recordEvicted9+ if width <= 0 || height <= 0 {
10-10+ // renderer.Frame permits a collapsed geometry. There is no layout to
11- if s.alternate != nil {11+ // preserve until a usable viewport is supplied again.
12- var shift int12+ width, height = max(width, 0), max(height, 0)
13- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)13+ s.buffer = newBuffer(width, height)
14- s.Row = clamp(s.Row-shift, 0, height-1)14+ s.Row, s.Col = 0, 0
15- s.Col = clamp(s.Col, 0, width-1)15+ if state := s.alternate; state != nil {
16- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)16+ state.buffer = newBuffer(width, height)
17- s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)17+ state.frame = state.buffer.frame
18- s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)18+ state.row, state.col = 0, 0
19- s.alternate.col = clamp(s.alternate.col, 0, width-1)19+ state.scrollTop, state.scrollBottom = 0, height-1
20- s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
21- s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
22- s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
23- if s.alternate.scrollTop >= s.alternate.scrollBottom {
24- s.alternate.scrollTop = 0
25- s.alternate.scrollBottom = max(height-1, 0)
26 }20 }
27 } else {21 } else {
28- var shift int22+ resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
29- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)23+ active := &bufferCursor{row: row, col: col}
30- s.Row = clamp(s.Row-shift, 0, height-1)24+ var savedPoint *bufferCursor
31- s.Col = clamp(s.Col, 0, width-1)25+ if saved != nil && saved.saved {
32- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)26+ savedPoint = &bufferCursor{row: saved.row, col: saved.col}
33- }27+ }
28+ evicted := b.resize(width, height, active, savedPoint)
29+ if evict {
30+ for _, line := range evicted {
31+ s.recordEvicted(line)
32+ }
33+ }
34+ if savedPoint != nil {
35+ saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
36+ }
37+ return active.row, clamp(active.col, 0, width-1)
38+ }
34 39
40+ if s.alternate != nil {
41+ // Both screen states are reflowed independently, but active and saved
42+ // cursors for each state are mapped by their single buffer layout pass.
43+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
44+ state := s.alternate
45+ if state.buffer == nil {
46+ state.buffer = bufferFromFrame(state.frame)
47+ }
48+ state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
49+ state.frame = state.buffer.frame
50+ state.scrollTop, state.scrollBottom = 0, height-1
51+ } else {
52+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
53+ }
54+ }
55+ s.Frame = s.buffer.frame
35 // A resize can split an in-flight escape sequence from the terminal state it56 // A resize can split an in-flight escape sequence from the terminal state it
36 // was meant to mutate; keep the durable child state but discard partial bytes.57 // was meant to mutate; keep the durable child state but discard partial bytes.
37 s.escapeBuf = s.escapeBuf[:0]58 s.escapeBuf = s.escapeBuf[:0]
@@ -39,34 +60,6 @@ func (s *Screen) Resize(width, height int) {
39 s.fullRedraw()60 s.fullRedraw()
40 }61 }
41 62
42-func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
43- next := renderer.NewFrame(newW, newH)
44- shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
45- if evict != nil {
46- for y := range shift {
47- evict(old.Row(y))
48- }
49- }
50- for dy := range newH {
51- sy := dy + shift
52- if sy >= old.Height {
53- break
54- }
55- copy(next.Row(dy), old.Row(sy))
56- repairFrameRow(next, dy)
57- }
58- return next, shift
59-}
60-
61-func resizedCursor(cur cursorState, shift, width, height int) cursorState {
62- if !cur.saved {
63- return cur
64- }
65- cur.row = clamp(cur.row-shift, 0, height-1)
66- cur.col = clamp(cur.col, 0, width-1)
67- return cur
68-}
69-
70 // cloneFrame produces an independent copy in canonical layout: it copies the63 // cloneFrame produces an independent copy in canonical layout: it copies the
71 // source's logical rows (via Row) into a fresh frame whose offsets are already64 // source's logical rows (via Row) into a fresh frame whose offsets are already
72 // canonical, so a rotated source is normalized in the clone.65 // canonical, so a rotated source is normalized in the clone.
M pkg/vt/screen_resize_test.go +48 -0
@@ -103,6 +103,54 @@ func TestResize(t *testing.T) {
103103 }
104104 },
105105 },
106+ {
107+ name: "soft wrapped lines reflow on shrink and growth",
108+ run: func(t *testing.T) {
109+ s := NewScreen(5, 3)
110+ s.Write([]byte("abcdefgh"))
111+
112+ s.Resize(3, 3)
113+ if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.Join(got, ",") != "abc,def,gh " {
114+ t.Fatalf("shrink rows = %q", got)
115+ }
116+
117+ s.Resize(5, 3)
118+ if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "abcde,fgh " {
119+ t.Fatalf("growth rows = %q", got)
120+ }
121+ },
122+ },
123+ {
124+ name: "erase preserves a soft wrap boundary",
125+ run: func(t *testing.T) {
126+ s := NewScreen(4, 2)
127+ s.Write([]byte("abcdef"))
128+ s.Write([]byte("\x1b[1;1H\x1b[2K"))
129+
130+ s.Resize(3, 2)
131+ if got := rowString(s, 0); got != "ef " {
132+ t.Fatalf("erased soft line reflow = %q", got)
133+ }
134+ },
135+ },
136+ {
137+ name: "wide edge padding does not become logical content",
138+ run: func(t *testing.T) {
139+ s := NewScreen(4, 3)
140+ s.Write([]byte("abc界x"))
141+
142+ s.Resize(3, 3)
143+ if got := rowString(s, 0); got != "abc" {
144+ t.Fatalf("first reflow row = %q", got)
145+ }
146+ assertCell(t, s, 0, 1, '界')
147+ if !cellAt(s, 1, 1).Continuation {
148+ t.Fatal("wide continuation was not retained")
149+ }
150+ assertCell(t, s, 2, 1, 'x')
151+ assertNoOrphanWideCells(t, s)
152+ },
153+ },
106154 {
107155 name: "width truncates and pads without reflow",
108156 run: func(t *testing.T) {
@@ -103,6 +103,54 @@ func TestResize(t *testing.T) {
103 }103 }
104 },104 },
105 },105 },
106+ {
107+ name: "soft wrapped lines reflow on shrink and growth",
108+ run: func(t *testing.T) {
109+ s := NewScreen(5, 3)
110+ s.Write([]byte("abcdefgh"))
111+
112+ s.Resize(3, 3)
113+ if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.Join(got, ",") != "abc,def,gh " {
114+ t.Fatalf("shrink rows = %q", got)
115+ }
116+
117+ s.Resize(5, 3)
118+ if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "abcde,fgh " {
119+ t.Fatalf("growth rows = %q", got)
120+ }
121+ },
122+ },
123+ {
124+ name: "erase preserves a soft wrap boundary",
125+ run: func(t *testing.T) {
126+ s := NewScreen(4, 2)
127+ s.Write([]byte("abcdef"))
128+ s.Write([]byte("\x1b[1;1H\x1b[2K"))
129+
130+ s.Resize(3, 2)
131+ if got := rowString(s, 0); got != "ef " {
132+ t.Fatalf("erased soft line reflow = %q", got)
133+ }
134+ },
135+ },
136+ {
137+ name: "wide edge padding does not become logical content",
138+ run: func(t *testing.T) {
139+ s := NewScreen(4, 3)
140+ s.Write([]byte("abc界x"))
141+
142+ s.Resize(3, 3)
143+ if got := rowString(s, 0); got != "abc" {
144+ t.Fatalf("first reflow row = %q", got)
145+ }
146+ assertCell(t, s, 0, 1, '界')
147+ if !cellAt(s, 1, 1).Continuation {
148+ t.Fatal("wide continuation was not retained")
149+ }
150+ assertCell(t, s, 2, 1, 'x')
151+ assertNoOrphanWideCells(t, s)
152+ },
153+ },
106 {154 {
107 name: "width truncates and pads without reflow",155 name: "width truncates and pads without reflow",
108 run: func(t *testing.T) {156 run: func(t *testing.T) {
M pkg/vt/screen_scroll.go +3 -0
@@ -18,6 +18,7 @@ func (s *Screen) index() {
1818 }
1919
2020 func (s *Screen) nextLine() {
21+ s.buffer.hard(s.Row)
2122 s.Col = 0
2223 s.index()
2324 }
@@ -69,6 +70,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
6970 // instead of copying cells. See renderer.Frame.ScrollUp.
7071 s.emitLineEvicted(top, n)
7172 s.Frame.ScrollUp(top, bottom, n)
73+ s.buffer.scrollUp(top, bottom, n)
7274 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
7375 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
7476 }
@@ -108,6 +110,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
108110 }
109111 // Full-width region: rotate line offsets instead of copying cells.
110112 s.Frame.ScrollDown(top, bottom, n)
113+ s.buffer.scrollDown(top, bottom, n)
111114 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
112115 }
113116
@@ -18,6 +18,7 @@ func (s *Screen) index() {
18 }18 }
19 19
20 func (s *Screen) nextLine() {20 func (s *Screen) nextLine() {
21+ s.buffer.hard(s.Row)
21 s.Col = 022 s.Col = 0
22 s.index()23 s.index()
23 }24 }
@@ -69,6 +70,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
69 // instead of copying cells. See renderer.Frame.ScrollUp.70 // instead of copying cells. See renderer.Frame.ScrollUp.
70 s.emitLineEvicted(top, n)71 s.emitLineEvicted(top, n)
71 s.Frame.ScrollUp(top, bottom, n)72 s.Frame.ScrollUp(top, bottom, n)
73+ s.buffer.scrollUp(top, bottom, n)
72 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})74 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
73 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})75 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
74 }76 }
@@ -108,6 +110,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
108 }110 }
109 // Full-width region: rotate line offsets instead of copying cells.111 // Full-width region: rotate line offsets instead of copying cells.
110 s.Frame.ScrollDown(top, bottom, n)112 s.Frame.ScrollDown(top, bottom, n)
113+ s.buffer.scrollDown(top, bottom, n)
111 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})114 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
112 }115 }
113 116
M pkg/vt/screen_write.go +7 -0
@@ -17,6 +17,7 @@ func (s *Screen) putRune(r rune) {
1717 if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
1818 s.Col = s.Frame.Width - 1
1919 }
20+ s.buffer.hard(s.Row)
2021 s.index()
2122 case '\b':
2223 if s.Col > 0 {
@@ -50,6 +51,7 @@ func (s *Screen) putPrintable(r rune) {
5051 }
5152 // Deferred wrap: cursor sits past the last column.
5253 if s.Col >= s.Frame.Width {
54+ s.buffer.soft(s.Row)
5355 s.Col = 0
5456 s.index()
5557 }
@@ -74,6 +76,8 @@ func (s *Screen) putPrintable(r rune) {
7476 }
7577 s.clearWidePairAt(s.Col, s.Row)
7678 s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
79+ s.buffer.truncate(s.Row, cx)
80+ s.buffer.continueRow(s.Row)
7781 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
7882 s.Col = 0
7983 s.index()
@@ -95,6 +99,7 @@ func (s *Screen) putPrintable(r rune) {
9599 row[x] = renderer.BlankCell()
96100 }
97101 s.repairRow(s.Row)
102+ s.buffer.insert(s.Row, s.Col, w)
98103 if leftSplit {
99104 insertDamageX = s.Col - 1
100105 }
@@ -114,6 +119,7 @@ func (s *Screen) putPrintable(r rune) {
114119 s.Frame.Set(x, s.Row, renderer.BlankCell())
115120 }
116121 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
122+ s.buffer.content(s.Row, s.Col+w)
117123 if w == 2 {
118124 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
119125 }
@@ -188,6 +194,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
188194 for x := x0; x < x1; x++ {
189195 s.Frame.Set(x, y, blank)
190196 }
197+ s.buffer.clear(y, x0, x1)
191198 return x0, x1 - x0
192199 }
193200
@@ -17,6 +17,7 @@ func (s *Screen) putRune(r rune) {
17 if s.Frame.Width > 0 && s.Col >= s.Frame.Width {17 if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
18 s.Col = s.Frame.Width - 118 s.Col = s.Frame.Width - 1
19 }19 }
20+ s.buffer.hard(s.Row)
20 s.index()21 s.index()
21 case '\b':22 case '\b':
22 if s.Col > 0 {23 if s.Col > 0 {
@@ -50,6 +51,7 @@ func (s *Screen) putPrintable(r rune) {
50 }51 }
51 // Deferred wrap: cursor sits past the last column.52 // Deferred wrap: cursor sits past the last column.
52 if s.Col >= s.Frame.Width {53 if s.Col >= s.Frame.Width {
54+ s.buffer.soft(s.Row)
53 s.Col = 055 s.Col = 0
54 s.index()56 s.index()
55 }57 }
@@ -74,6 +76,8 @@ func (s *Screen) putPrintable(r rune) {
74 }76 }
75 s.clearWidePairAt(s.Col, s.Row)77 s.clearWidePairAt(s.Col, s.Row)
76 s.Frame.Set(s.Col, s.Row, renderer.BlankCell())78 s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
79+ s.buffer.truncate(s.Row, cx)
80+ s.buffer.continueRow(s.Row)
77 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})81 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
78 s.Col = 082 s.Col = 0
79 s.index()83 s.index()
@@ -95,6 +99,7 @@ func (s *Screen) putPrintable(r rune) {
95 row[x] = renderer.BlankCell()99 row[x] = renderer.BlankCell()
96 }100 }
97 s.repairRow(s.Row)101 s.repairRow(s.Row)
102+ s.buffer.insert(s.Row, s.Col, w)
98 if leftSplit {103 if leftSplit {
99 insertDamageX = s.Col - 1104 insertDamageX = s.Col - 1
100 }105 }
@@ -114,6 +119,7 @@ func (s *Screen) putPrintable(r rune) {
114 s.Frame.Set(x, s.Row, renderer.BlankCell())119 s.Frame.Set(x, s.Row, renderer.BlankCell())
115 }120 }
116 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})121 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
122+ s.buffer.content(s.Row, s.Col+w)
117 if w == 2 {123 if w == 2 {
118 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})124 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
119 }125 }
@@ -188,6 +194,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
188 for x := x0; x < x1; x++ {194 for x := x0; x < x1; x++ {
189 s.Frame.Set(x, y, blank)195 s.Frame.Set(x, y, blank)
190 }196 }
197+ s.buffer.clear(y, x0, x1)
191 return x0, x1 - x0198 return x0, x1 - x0
192 }199 }
193 200
M pkg/vt/snapshot.go +94 -17
@@ -8,7 +8,10 @@ import (
88 "github.com/bnema/vev/pkg/renderer"
99 )
1010
11-const visibleMagic = "VTV1"
11+const (
12+ visibleMagic = "VTV2"
13+ visibleBoundaryBytes = 5
14+)
1215
1316 // DecodeStats describes resources declared by one canonical VT blob.
1417 type DecodeStats struct {
@@ -112,15 +115,40 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale
112115 // MarshalVisible encodes the exact visible primary frame, including every
113116 // blank, row width, and terminal cell attribute.
114117 func MarshalVisible(frame renderer.Frame) ([]byte, error) {
115- if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
118+ if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {
116119 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
117120 }
118- cells := uint64(frame.Width) * uint64(frame.Height)
119- bytes, ok := historyCellByteCount(cells)
120- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
121+ boundaries := make([]lineBoundary, frame.Height)
122+ for y := range boundaries {
123+ for x := frame.Width - 1; x >= 0; x-- {
124+ if !frame.At(x, y).Equal(renderer.BlankCell()) {
125+ boundaries[y].end = x + 1
126+ break
127+ }
128+ }
129+ }
130+ return marshalVisible(frame, boundaries)
131+}
132+
133+// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
134+// alongside its cells so a restored viewport can subsequently reflow.
135+func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
136+ b := s.buffer
137+ if s.alternate != nil {
138+ b = s.alternate.buffer
139+ }
140+ if b == nil {
141+ return MarshalVisible(s.PrimaryVisibleFrame())
142+ }
143+ return marshalVisible(b.frame, b.boundaries)
144+}
145+
146+func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
147+ _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)
148+ if !ok || len(boundaries) != frame.Height {
121149 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
122150 }
123- out := make([]byte, 0, 13+bytes)
151+ out := make([]byte, 0, int(13+bytes+boundaryBytes))
124152 out = append(out, visibleMagic...)
125153 out = append(out, historyVersion)
126154 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
@@ -133,6 +161,14 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
133161 out = appendHistoryCell(out, cell)
134162 }
135163 }
164+ for _, boundary := range boundaries {
165+ out = binary.BigEndian.AppendUint32(out, uint32(boundary.end))
166+ if boundary.soft {
167+ out = append(out, 1)
168+ } else {
169+ out = append(out, 0)
170+ }
171+ }
136172 return out, nil
137173 }
138174
@@ -143,12 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
143179 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
144180 }
145181 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
146- if width != 0 && height > math.MaxUint64/width {
147- return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
148- }
149- cells := width * height
150- bytes, ok := historyCellByteCount(cells)
151- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {
182+ cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height)
183+ if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes {
152184 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
153185 }
154186 p := historyParser{data: data[13:]}
@@ -158,24 +190,65 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
158190 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
159191 }
160192 }
193+ for range height {
194+ if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 {
195+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
196+ }
197+ p.data = p.data[5:]
198+ }
161199 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
162200 }
163201
202+func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) {
203+ if width < 0 || height < 0 {
204+ return 0, 0, 0, false
205+ }
206+ return visibleEncodingBudget64(uint64(width), uint64(height))
207+}
208+
209+func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) {
210+ if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes {
211+ return 0, 0, 0, false
212+ }
213+ cells = width * height
214+ cellBytes, ok = historyCellByteCount(cells)
215+ if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes {
216+ return 0, 0, 0, false
217+ }
218+ boundaryBytes = height * visibleBoundaryBytes
219+ if boundaryBytes > maxVisibleBoundaryBytes {
220+ return 0, 0, 0, false
221+ }
222+ return cells, cellBytes, boundaryBytes, true
223+}
224+
164225 // UnmarshalVisible decodes a preflighted exact visible frame.
165226 func UnmarshalVisible(data []byte) (renderer.Frame, error) {
227+ frame, _, err := unmarshalVisible(data)
228+ return frame, err
229+}
230+
231+func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
166232 if _, err := PreflightVisibleBlob(data); err != nil {
167- return renderer.Frame{}, err
233+ return renderer.Frame{}, nil, err
168234 }
169235 width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
170236 frame := renderer.NewFrame(width, height)
171- p := historyParser{data: data[13:]}
237+ cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height))
238+ p := historyParser{data: data[13 : 13+cellBytes]}
172239 for y := range height {
173240 for x := range width {
174241 cell, _ := p.cell()
175242 frame.Set(x, y, cell)
176243 }
177244 }
178- return frame, nil
245+ boundaries := make([]lineBoundary, height)
246+ offset := 13 + int(cellBytes)
247+ for y := range boundaries {
248+ boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1}
249+ offset += 5
250+ }
251+ return frame, boundaries, nil
179252 }
180253
181254 // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
@@ -203,17 +276,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
203276
204277 // RestorePrimaryVisible replaces the primary frame from an exact visible blob.
205278 func (s *Screen) RestorePrimaryVisible(blob []byte) error {
206- frame, err := UnmarshalVisible(blob)
279+ frame, boundaries, err := unmarshalVisible(blob)
207280 if err != nil {
208281 return err
209282 }
283+ b := bufferFromFrame(frame)
284+ b.boundaries = boundaries
210285 // A persisted primary frame is authoritative: restoring it directly keeps
211286 // exact row widths and height even when a collapsed layout chose a smaller
212287 // initial PTY size. A subsequent resize reconciles it with live geometry.
213288 if s.alternate != nil {
214289 s.alternate.frame = frame
290+ s.alternate.buffer = b
215291 } else {
216- s.Frame = frame
292+ s.buffer = b
293+ s.Frame = b.frame
217294 }
218295 s.fullRedraw()
219296 return nil
@@ -8,7 +8,10 @@ import (
8 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
9 )9 )
10 10
11-const visibleMagic = "VTV1"11+const (
12+ visibleMagic = "VTV2"
13+ visibleBoundaryBytes = 5
14+)
12 15
13 // DecodeStats describes resources declared by one canonical VT blob.16 // DecodeStats describes resources declared by one canonical VT blob.
14 type DecodeStats struct {17 type DecodeStats struct {
@@ -112,15 +115,40 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale
112 // MarshalVisible encodes the exact visible primary frame, including every115 // MarshalVisible encodes the exact visible primary frame, including every
113 // blank, row width, and terminal cell attribute.116 // blank, row width, and terminal cell attribute.
114 func MarshalVisible(frame renderer.Frame) ([]byte, error) {117 func MarshalVisible(frame renderer.Frame) ([]byte, error) {
115- if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {118+ if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {
116 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)119 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
117 }120 }
118- cells := uint64(frame.Width) * uint64(frame.Height)121+ boundaries := make([]lineBoundary, frame.Height)
119- bytes, ok := historyCellByteCount(cells)122+ for y := range boundaries {
120- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {123+ for x := frame.Width - 1; x >= 0; x-- {
124+ if !frame.At(x, y).Equal(renderer.BlankCell()) {
125+ boundaries[y].end = x + 1
126+ break
127+ }
128+ }
129+ }
130+ return marshalVisible(frame, boundaries)
131+}
132+
133+// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
134+// alongside its cells so a restored viewport can subsequently reflow.
135+func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
136+ b := s.buffer
137+ if s.alternate != nil {
138+ b = s.alternate.buffer
139+ }
140+ if b == nil {
141+ return MarshalVisible(s.PrimaryVisibleFrame())
142+ }
143+ return marshalVisible(b.frame, b.boundaries)
144+}
145+
146+func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
147+ _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)
148+ if !ok || len(boundaries) != frame.Height {
121 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)149 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
122 }150 }
123- out := make([]byte, 0, 13+bytes)151+ out := make([]byte, 0, int(13+bytes+boundaryBytes))
124 out = append(out, visibleMagic...)152 out = append(out, visibleMagic...)
125 out = append(out, historyVersion)153 out = append(out, historyVersion)
126 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))154 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
@@ -133,6 +161,14 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
133 out = appendHistoryCell(out, cell)161 out = appendHistoryCell(out, cell)
134 }162 }
135 }163 }
164+ for _, boundary := range boundaries {
165+ out = binary.BigEndian.AppendUint32(out, uint32(boundary.end))
166+ if boundary.soft {
167+ out = append(out, 1)
168+ } else {
169+ out = append(out, 0)
170+ }
171+ }
136 return out, nil172 return out, nil
137 }173 }
138 174
@@ -143,12 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
143 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)179 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
144 }180 }
145 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))181 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
146- if width != 0 && height > math.MaxUint64/width {182+ cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height)
147- return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)183+ if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes {
148- }
149- cells := width * height
150- bytes, ok := historyCellByteCount(cells)
151- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {
152 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)184 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
153 }185 }
154 p := historyParser{data: data[13:]}186 p := historyParser{data: data[13:]}
@@ -158,24 +190,65 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
158 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)190 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
159 }191 }
160 }192 }
193+ for range height {
194+ if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 {
195+ return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
196+ }
197+ p.data = p.data[5:]
198+ }
161 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil199 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
162 }200 }
163 201
202+func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) {
203+ if width < 0 || height < 0 {
204+ return 0, 0, 0, false
205+ }
206+ return visibleEncodingBudget64(uint64(width), uint64(height))
207+}
208+
209+func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) {
210+ if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes {
211+ return 0, 0, 0, false
212+ }
213+ cells = width * height
214+ cellBytes, ok = historyCellByteCount(cells)
215+ if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes {
216+ return 0, 0, 0, false
217+ }
218+ boundaryBytes = height * visibleBoundaryBytes
219+ if boundaryBytes > maxVisibleBoundaryBytes {
220+ return 0, 0, 0, false
221+ }
222+ return cells, cellBytes, boundaryBytes, true
223+}
224+
164 // UnmarshalVisible decodes a preflighted exact visible frame.225 // UnmarshalVisible decodes a preflighted exact visible frame.
165 func UnmarshalVisible(data []byte) (renderer.Frame, error) {226 func UnmarshalVisible(data []byte) (renderer.Frame, error) {
227+ frame, _, err := unmarshalVisible(data)
228+ return frame, err
229+}
230+
231+func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
166 if _, err := PreflightVisibleBlob(data); err != nil {232 if _, err := PreflightVisibleBlob(data); err != nil {
167- return renderer.Frame{}, err233+ return renderer.Frame{}, nil, err
168 }234 }
169 width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))235 width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
170 frame := renderer.NewFrame(width, height)236 frame := renderer.NewFrame(width, height)
171- p := historyParser{data: data[13:]}237+ cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height))
238+ p := historyParser{data: data[13 : 13+cellBytes]}
172 for y := range height {239 for y := range height {
173 for x := range width {240 for x := range width {
174 cell, _ := p.cell()241 cell, _ := p.cell()
175 frame.Set(x, y, cell)242 frame.Set(x, y, cell)
176 }243 }
177 }244 }
178- return frame, nil245+ boundaries := make([]lineBoundary, height)
246+ offset := 13 + int(cellBytes)
247+ for y := range boundaries {
248+ boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1}
249+ offset += 5
250+ }
251+ return frame, boundaries, nil
179 }252 }
180 253
181 // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.254 // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
@@ -203,17 +276,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
203 276
204 // RestorePrimaryVisible replaces the primary frame from an exact visible blob.277 // RestorePrimaryVisible replaces the primary frame from an exact visible blob.
205 func (s *Screen) RestorePrimaryVisible(blob []byte) error {278 func (s *Screen) RestorePrimaryVisible(blob []byte) error {
206- frame, err := UnmarshalVisible(blob)279+ frame, boundaries, err := unmarshalVisible(blob)
207 if err != nil {280 if err != nil {
208 return err281 return err
209 }282 }
283+ b := bufferFromFrame(frame)
284+ b.boundaries = boundaries
210 // A persisted primary frame is authoritative: restoring it directly keeps285 // A persisted primary frame is authoritative: restoring it directly keeps
211 // exact row widths and height even when a collapsed layout chose a smaller286 // exact row widths and height even when a collapsed layout chose a smaller
212 // initial PTY size. A subsequent resize reconciles it with live geometry.287 // initial PTY size. A subsequent resize reconciles it with live geometry.
213 if s.alternate != nil {288 if s.alternate != nil {
214 s.alternate.frame = frame289 s.alternate.frame = frame
290+ s.alternate.buffer = b
215 } else {291 } else {
216- s.Frame = frame292+ s.buffer = b
293+ s.Frame = b.frame
217 }294 }
218 s.fullRedraw()295 s.fullRedraw()
219 return nil296 return nil
A pkg/vt/snapshot_boundary_test.go +43 -0
@@ -0,0 +1,43 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestPreflightVisibleRejectsMalformedBoundaries(t *testing.T) {
11+ s := NewScreen(4, 2)
12+ s.Write([]byte("abcdef"))
13+ blob, err := s.MarshalPrimaryVisible()
14+ require.NoError(t, err)
15+
16+ boundaryOffset := 13 + 2*4*historyCellBytes
17+ tests := []struct {
18+ name string
19+ mutate func([]byte)
20+ }{
21+ {
22+ name: "boundary end exceeds row width",
23+ mutate: func(data []byte) {
24+ binary.BigEndian.PutUint32(data[boundaryOffset:boundaryOffset+4], 5)
25+ },
26+ },
27+ {
28+ name: "soft flag is not canonical",
29+ mutate: func(data []byte) {
30+ data[boundaryOffset+4] = 2
31+ },
32+ },
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ data := append([]byte(nil), blob...)
38+ tt.mutate(data)
39+ _, err := PreflightVisibleBlob(data)
40+ require.Error(t, err)
41+ })
42+ }
43+}
@@ -0,0 +1,43 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestPreflightVisibleRejectsMalformedBoundaries(t *testing.T) {
11+ s := NewScreen(4, 2)
12+ s.Write([]byte("abcdef"))
13+ blob, err := s.MarshalPrimaryVisible()
14+ require.NoError(t, err)
15+
16+ boundaryOffset := 13 + 2*4*historyCellBytes
17+ tests := []struct {
18+ name string
19+ mutate func([]byte)
20+ }{
21+ {
22+ name: "boundary end exceeds row width",
23+ mutate: func(data []byte) {
24+ binary.BigEndian.PutUint32(data[boundaryOffset:boundaryOffset+4], 5)
25+ },
26+ },
27+ {
28+ name: "soft flag is not canonical",
29+ mutate: func(data []byte) {
30+ data[boundaryOffset+4] = 2
31+ },
32+ },
33+ }
34+
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ data := append([]byte(nil), blob...)
38+ tt.mutate(data)
39+ _, err := PreflightVisibleBlob(data)
40+ require.Error(t, err)
41+ })
42+ }
43+}
M pkg/vt/snapshot_test.go +14 -0
@@ -7,6 +7,20 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10+func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) {
11+ s := NewScreen(5, 3)
12+ s.Write([]byte("abcdefgh"))
13+ blob, err := s.MarshalPrimaryVisible()
14+ require.NoError(t, err)
15+
16+ restored := NewScreen(1, 1)
17+ require.NoError(t, restored.RestorePrimaryVisible(blob))
18+ restored.Resize(3, 3)
19+ require.Equal(t, "abc", rowString(restored.Frame.Row(0)))
20+ require.Equal(t, "def", rowString(restored.Frame.Row(1)))
21+ require.Equal(t, "gh ", rowString(restored.Frame.Row(2)))
22+}
23+
1024 func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) {
1125 s := NewScreen(6, 2)
1226 s.Write([]byte("hello"))
@@ -7,6 +7,20 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
10+func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) {
11+ s := NewScreen(5, 3)
12+ s.Write([]byte("abcdefgh"))
13+ blob, err := s.MarshalPrimaryVisible()
14+ require.NoError(t, err)
15+
16+ restored := NewScreen(1, 1)
17+ require.NoError(t, restored.RestorePrimaryVisible(blob))
18+ restored.Resize(3, 3)
19+ require.Equal(t, "abc", rowString(restored.Frame.Row(0)))
20+ require.Equal(t, "def", rowString(restored.Frame.Row(1)))
21+ require.Equal(t, "gh ", rowString(restored.Frame.Row(2)))
22+}
23+
10 func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) {24 func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) {
11 s := NewScreen(6, 2)25 s := NewScreen(6, 2)
12 s.Write([]byte("hello"))26 s.Write([]byte("hello"))

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