detached 56e46d3

fix(vt): reflow visible lines on resize

commit
56e46d303cf73a46a0c7a65d09fbc88c4df9f431
author
brice · · 2026-07-15 15:30 UTC
parent
cb4ef6c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +497 -78
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 {
A pkg/vt/buffer.go +282 -0
@@ -0,0 +1,282 @@
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) hard(y int) {
41+ if y >= 0 && y < len(b.boundaries) {
42+ b.boundaries[y].soft = false
43+ }
44+}
45+
46+func (b *buffer) soft(y int) {
47+ if y >= 0 && y < len(b.boundaries) {
48+ b.boundaries[y].end = b.frame.Width
49+ b.boundaries[y].soft = true
50+ }
51+}
52+
53+func (b *buffer) continueRow(y int) {
54+ if y >= 0 && y < len(b.boundaries) {
55+ b.boundaries[y].soft = true
56+ }
57+}
58+
59+func (b *buffer) clear(y, x0, x1 int) {
60+ if y < 0 || y >= len(b.boundaries) {
61+ return
62+ }
63+ b.content(y, x1)
64+ if x0 == 0 && x1 >= b.frame.Width {
65+ b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft}
66+ }
67+}
68+
69+func (b *buffer) scrollUp(top, bottom, n int) {
70+ for ; n > 0; n-- {
71+ copy(b.boundaries[top:bottom], b.boundaries[top+1:bottom+1])
72+ b.boundaries[bottom] = lineBoundary{}
73+ }
74+}
75+
76+func (b *buffer) scrollDown(top, bottom, n int) {
77+ for ; n > 0; n-- {
78+ copy(b.boundaries[top+1:bottom+1], b.boundaries[top:bottom])
79+ b.boundaries[top] = lineBoundary{}
80+ }
81+}
82+
83+func (b *buffer) hydrate() {
84+ for y := range b.boundaries {
85+ if b.boundaries[y].end != 0 {
86+ continue
87+ }
88+ for x := b.frame.Width - 1; x >= 0; x-- {
89+ if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
90+ b.boundaries[y].end = x + 1
91+ break
92+ }
93+ }
94+ }
95+}
96+
97+type bufferCursor struct{ row, col int }
98+
99+func (b *buffer) hasSoft() bool {
100+ for _, boundary := range b.boundaries {
101+ if boundary.soft {
102+ return true
103+ }
104+ }
105+ return false
106+}
107+
108+// resizeFixed keeps hard physical lines independent. It is the common shell
109+// path and avoids constructing logical-line scratch state when nothing wraps.
110+func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]renderer.Cell {
111+ anchor := 0
112+ if len(cursors) > 0 && cursors[0] != nil {
113+ anchor = cursors[0].row
114+ }
115+ shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
116+ evicted := make([][]renderer.Cell, 0, shift)
117+ for y := 0; y < shift; y++ {
118+ evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
119+ }
120+ next := newBuffer(width, height)
121+ for y := 0; y < height; y++ {
122+ sy := y + shift
123+ if sy >= b.frame.Height {
124+ break
125+ }
126+ copy(next.frame.Row(y), b.frame.Row(sy))
127+ next.boundaries[y] = b.boundaries[sy]
128+ next.boundaries[y].end = min(next.boundaries[y].end, width)
129+ repairFrameRow(next.frame, y)
130+ }
131+ for _, cur := range cursors {
132+ if cur != nil {
133+ cur.row = clamp(cur.row-shift, 0, height-1)
134+ cur.col = clamp(cur.col, 0, width)
135+ }
136+ }
137+ *b = *next
138+ return evicted
139+}
140+
141+// resize lays out only the current grid. It returns the top rows discarded by
142+// height reduction and maps every supplied cursor in the same layout pass.
143+func (b *buffer) resize(width, height int, cursors ...*bufferCursor) [][]renderer.Cell {
144+ b.hydrate()
145+ if !b.hasSoft() {
146+ return b.resizeFixed(width, height, cursors...)
147+ }
148+ for _, cur := range cursors {
149+ if cur != nil {
150+ b.content(cur.row, cur.col)
151+ }
152+ }
153+ oldW := b.frame.Width
154+ type logical struct {
155+ cells []renderer.Cell
156+ points map[int]int // old row/column key -> cell offset
157+ reflow bool
158+ }
159+ var lines []logical
160+ for y := 0; y < b.frame.Height; {
161+ line := logical{points: make(map[int]int)}
162+ for {
163+ end := b.boundaries[y].end
164+ for x := 0; x <= oldW; x++ {
165+ line.points[y*(oldW+1)+x] = len(line.cells) + min(x, end)
166+ }
167+ line.cells = append(line.cells, b.frame.Row(y)[:end]...)
168+ soft := b.boundaries[y].soft && y+1 < b.frame.Height
169+ line.reflow = line.reflow || soft
170+ y++
171+ if !soft {
172+ break
173+ }
174+ }
175+ lines = append(lines, line)
176+ }
177+
178+ type point struct{ row, col int }
179+ cursorOffsets := make([]struct{ line, offset int }, len(cursors))
180+ for ci, cur := range cursors {
181+ if cur == nil {
182+ continue
183+ }
184+ key := clamp(cur.row, 0, max(b.frame.Height-1, 0))*(oldW+1) + clamp(cur.col, 0, oldW)
185+ for li := range lines {
186+ if offset, ok := lines[li].points[key]; ok {
187+ cursorOffsets[ci] = struct{ line, offset int }{li, offset}
188+ break
189+ }
190+ }
191+ }
192+
193+ var rows [][]renderer.Cell
194+ var bounds []lineBoundary
195+ positions := make([][]point, len(lines))
196+ for li, line := range lines {
197+ positions[li] = make([]point, len(line.cells)+1)
198+ row := make([]renderer.Cell, width)
199+ for x := range row {
200+ row[x] = renderer.BlankCell()
201+ }
202+ x := 0
203+ for i := 0; i < len(line.cells); {
204+ if line.cells[i].Continuation { // corrupt input is repaired below.
205+ i++
206+ continue
207+ }
208+ w := renderer.RuneWidth(line.cells[i].Rune)
209+ wide := w == 2 && i+1 < len(line.cells) && line.cells[i+1].Continuation
210+ if !wide || width < 2 {
211+ w = 1
212+ }
213+ if x+w > width && x > 0 {
214+ if !line.reflow {
215+ for j := i; j <= len(line.cells); j++ {
216+ positions[li][j] = point{len(rows), x}
217+ }
218+ break
219+ }
220+ rows = append(rows, row)
221+ bounds = append(bounds, lineBoundary{end: x, soft: true})
222+ row = make([]renderer.Cell, width)
223+ for j := range row {
224+ row[j] = renderer.BlankCell()
225+ }
226+ x = 0
227+ }
228+ positions[li][i] = point{len(rows), x}
229+ if w == 2 && width >= 2 {
230+ row[x], row[x+1] = line.cells[i], line.cells[i+1]
231+ positions[li][i+1] = point{len(rows), x + 1}
232+ x += 2
233+ i += 2
234+ } else {
235+ cell := line.cells[i]
236+ if wide {
237+ cell.Rune = '\uFFFD'
238+ cell.Continuation = false
239+ }
240+ row[x] = cell
241+ x++
242+ i++
243+ }
244+ }
245+ positions[li][len(line.cells)] = point{len(rows), x}
246+ rows = append(rows, row)
247+ bounds = append(bounds, lineBoundary{end: x})
248+ }
249+ if len(rows) == 0 {
250+ rows = append(rows, make([]renderer.Cell, width))
251+ for x := range rows[0] {
252+ rows[0][x] = renderer.BlankCell()
253+ }
254+ bounds = append(bounds, lineBoundary{})
255+ }
256+
257+ anchor := 0
258+ if len(cursors) > 0 && cursors[0] != nil {
259+ co := cursorOffsets[0]
260+ anchor = positions[co.line][min(co.offset, len(positions[co.line])-1)].row
261+ }
262+ shift := clamp(anchor-(height-1), 0, max(len(rows)-height, 0))
263+ var evicted [][]renderer.Cell
264+ for y := 0; y < shift; y++ {
265+ evicted = append(evicted, append([]renderer.Cell(nil), rows[y]...))
266+ }
267+ next := newBuffer(width, height)
268+ for y := 0; y < height && y+shift < len(rows); y++ {
269+ copy(next.frame.Row(y), rows[y+shift])
270+ next.boundaries[y] = bounds[y+shift]
271+ }
272+ for ci, cur := range cursors {
273+ if cur == nil {
274+ continue
275+ }
276+ co := cursorOffsets[ci]
277+ p := positions[co.line][min(co.offset, len(positions[co.line])-1)]
278+ cur.row, cur.col = clamp(p.row-shift, 0, height-1), clamp(p.col, 0, width)
279+ }
280+ *b = *next
281+ return evicted
282+}
@@ -0,0 +1,282 @@
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) hard(y int) {
41+ if y >= 0 && y < len(b.boundaries) {
42+ b.boundaries[y].soft = false
43+ }
44+}
45+
46+func (b *buffer) soft(y int) {
47+ if y >= 0 && y < len(b.boundaries) {
48+ b.boundaries[y].end = b.frame.Width
49+ b.boundaries[y].soft = true
50+ }
51+}
52+
53+func (b *buffer) continueRow(y int) {
54+ if y >= 0 && y < len(b.boundaries) {
55+ b.boundaries[y].soft = true
56+ }
57+}
58+
59+func (b *buffer) clear(y, x0, x1 int) {
60+ if y < 0 || y >= len(b.boundaries) {
61+ return
62+ }
63+ b.content(y, x1)
64+ if x0 == 0 && x1 >= b.frame.Width {
65+ b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft}
66+ }
67+}
68+
69+func (b *buffer) scrollUp(top, bottom, n int) {
70+ for ; n > 0; n-- {
71+ copy(b.boundaries[top:bottom], b.boundaries[top+1:bottom+1])
72+ b.boundaries[bottom] = lineBoundary{}
73+ }
74+}
75+
76+func (b *buffer) scrollDown(top, bottom, n int) {
77+ for ; n > 0; n-- {
78+ copy(b.boundaries[top+1:bottom+1], b.boundaries[top:bottom])
79+ b.boundaries[top] = lineBoundary{}
80+ }
81+}
82+
83+func (b *buffer) hydrate() {
84+ for y := range b.boundaries {
85+ if b.boundaries[y].end != 0 {
86+ continue
87+ }
88+ for x := b.frame.Width - 1; x >= 0; x-- {
89+ if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
90+ b.boundaries[y].end = x + 1
91+ break
92+ }
93+ }
94+ }
95+}
96+
97+type bufferCursor struct{ row, col int }
98+
99+func (b *buffer) hasSoft() bool {
100+ for _, boundary := range b.boundaries {
101+ if boundary.soft {
102+ return true
103+ }
104+ }
105+ return false
106+}
107+
108+// resizeFixed keeps hard physical lines independent. It is the common shell
109+// path and avoids constructing logical-line scratch state when nothing wraps.
110+func (b *buffer) resizeFixed(width, height int, cursors ...*bufferCursor) [][]renderer.Cell {
111+ anchor := 0
112+ if len(cursors) > 0 && cursors[0] != nil {
113+ anchor = cursors[0].row
114+ }
115+ shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
116+ evicted := make([][]renderer.Cell, 0, shift)
117+ for y := 0; y < shift; y++ {
118+ evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
119+ }
120+ next := newBuffer(width, height)
121+ for y := 0; y < height; y++ {
122+ sy := y + shift
123+ if sy >= b.frame.Height {
124+ break
125+ }
126+ copy(next.frame.Row(y), b.frame.Row(sy))
127+ next.boundaries[y] = b.boundaries[sy]
128+ next.boundaries[y].end = min(next.boundaries[y].end, width)
129+ repairFrameRow(next.frame, y)
130+ }
131+ for _, cur := range cursors {
132+ if cur != nil {
133+ cur.row = clamp(cur.row-shift, 0, height-1)
134+ cur.col = clamp(cur.col, 0, width)
135+ }
136+ }
137+ *b = *next
138+ return evicted
139+}
140+
141+// resize lays out only the current grid. It returns the top rows discarded by
142+// height reduction and maps every supplied cursor in the same layout pass.
143+func (b *buffer) resize(width, height int, cursors ...*bufferCursor) [][]renderer.Cell {
144+ b.hydrate()
145+ if !b.hasSoft() {
146+ return b.resizeFixed(width, height, cursors...)
147+ }
148+ for _, cur := range cursors {
149+ if cur != nil {
150+ b.content(cur.row, cur.col)
151+ }
152+ }
153+ oldW := b.frame.Width
154+ type logical struct {
155+ cells []renderer.Cell
156+ points map[int]int // old row/column key -> cell offset
157+ reflow bool
158+ }
159+ var lines []logical
160+ for y := 0; y < b.frame.Height; {
161+ line := logical{points: make(map[int]int)}
162+ for {
163+ end := b.boundaries[y].end
164+ for x := 0; x <= oldW; x++ {
165+ line.points[y*(oldW+1)+x] = len(line.cells) + min(x, end)
166+ }
167+ line.cells = append(line.cells, b.frame.Row(y)[:end]...)
168+ soft := b.boundaries[y].soft && y+1 < b.frame.Height
169+ line.reflow = line.reflow || soft
170+ y++
171+ if !soft {
172+ break
173+ }
174+ }
175+ lines = append(lines, line)
176+ }
177+
178+ type point struct{ row, col int }
179+ cursorOffsets := make([]struct{ line, offset int }, len(cursors))
180+ for ci, cur := range cursors {
181+ if cur == nil {
182+ continue
183+ }
184+ key := clamp(cur.row, 0, max(b.frame.Height-1, 0))*(oldW+1) + clamp(cur.col, 0, oldW)
185+ for li := range lines {
186+ if offset, ok := lines[li].points[key]; ok {
187+ cursorOffsets[ci] = struct{ line, offset int }{li, offset}
188+ break
189+ }
190+ }
191+ }
192+
193+ var rows [][]renderer.Cell
194+ var bounds []lineBoundary
195+ positions := make([][]point, len(lines))
196+ for li, line := range lines {
197+ positions[li] = make([]point, len(line.cells)+1)
198+ row := make([]renderer.Cell, width)
199+ for x := range row {
200+ row[x] = renderer.BlankCell()
201+ }
202+ x := 0
203+ for i := 0; i < len(line.cells); {
204+ if line.cells[i].Continuation { // corrupt input is repaired below.
205+ i++
206+ continue
207+ }
208+ w := renderer.RuneWidth(line.cells[i].Rune)
209+ wide := w == 2 && i+1 < len(line.cells) && line.cells[i+1].Continuation
210+ if !wide || width < 2 {
211+ w = 1
212+ }
213+ if x+w > width && x > 0 {
214+ if !line.reflow {
215+ for j := i; j <= len(line.cells); j++ {
216+ positions[li][j] = point{len(rows), x}
217+ }
218+ break
219+ }
220+ rows = append(rows, row)
221+ bounds = append(bounds, lineBoundary{end: x, soft: true})
222+ row = make([]renderer.Cell, width)
223+ for j := range row {
224+ row[j] = renderer.BlankCell()
225+ }
226+ x = 0
227+ }
228+ positions[li][i] = point{len(rows), x}
229+ if w == 2 && width >= 2 {
230+ row[x], row[x+1] = line.cells[i], line.cells[i+1]
231+ positions[li][i+1] = point{len(rows), x + 1}
232+ x += 2
233+ i += 2
234+ } else {
235+ cell := line.cells[i]
236+ if wide {
237+ cell.Rune = '\uFFFD'
238+ cell.Continuation = false
239+ }
240+ row[x] = cell
241+ x++
242+ i++
243+ }
244+ }
245+ positions[li][len(line.cells)] = point{len(rows), x}
246+ rows = append(rows, row)
247+ bounds = append(bounds, lineBoundary{end: x})
248+ }
249+ if len(rows) == 0 {
250+ rows = append(rows, make([]renderer.Cell, width))
251+ for x := range rows[0] {
252+ rows[0][x] = renderer.BlankCell()
253+ }
254+ bounds = append(bounds, lineBoundary{})
255+ }
256+
257+ anchor := 0
258+ if len(cursors) > 0 && cursors[0] != nil {
259+ co := cursorOffsets[0]
260+ anchor = positions[co.line][min(co.offset, len(positions[co.line])-1)].row
261+ }
262+ shift := clamp(anchor-(height-1), 0, max(len(rows)-height, 0))
263+ var evicted [][]renderer.Cell
264+ for y := 0; y < shift; y++ {
265+ evicted = append(evicted, append([]renderer.Cell(nil), rows[y]...))
266+ }
267+ next := newBuffer(width, height)
268+ for y := 0; y < height && y+shift < len(rows); y++ {
269+ copy(next.frame.Row(y), rows[y+shift])
270+ next.boundaries[y] = bounds[y+shift]
271+ }
272+ for ci, cur := range cursors {
273+ if cur == nil {
274+ continue
275+ }
276+ co := cursorOffsets[ci]
277+ p := positions[co.line][min(co.offset, len(positions[co.line])-1)]
278+ cur.row, cur.col = clamp(p.row-shift, 0, height-1), clamp(p.col, 0, width)
279+ }
280+ *b = *next
281+ return evicted
282+}
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 +39 -49
@@ -6,32 +6,50 @@ func (s *Screen) Resize(width, height int) {
66 if width == s.Frame.Width && height == s.Frame.Height {
77 return
88 }
9- evict := s.recordEvicted
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+ s.buffer = newBuffer(max(width, 0), max(height, 0))
13+ s.Frame = s.buffer.frame
14+ s.Row, s.Col = 0, 0
15+ s.resetScrollRegion()
16+ s.fullRedraw()
17+ return
18+ }
19+ resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
20+ active := &bufferCursor{row: row, col: col}
21+ var savedPoint *bufferCursor
22+ if saved != nil && saved.saved {
23+ savedPoint = &bufferCursor{row: saved.row, col: saved.col}
24+ }
25+ cursors := []*bufferCursor{active, savedPoint}
26+ evicted := b.resize(width, height, cursors...)
27+ if evict {
28+ for _, line := range evicted {
29+ s.recordEvicted(line)
30+ }
31+ }
32+ if savedPoint != nil {
33+ saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
34+ }
35+ return active.row, clamp(active.col, 0, width-1)
36+ }
1037
1138 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)
39+ // Both screen states are reflowed independently, but active and saved
40+ // cursors for each state are mapped by their single buffer layout pass.
41+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
42+ state := s.alternate
43+ if state.buffer == nil {
44+ state.buffer = bufferFromFrame(state.frame)
2645 }
46+ state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
47+ state.frame = state.buffer.frame
48+ state.scrollTop, state.scrollBottom = 0, height-1
2749 } 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)
50+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
3351 }
34-
52+ s.Frame = s.buffer.frame
3553 // A resize can split an in-flight escape sequence from the terminal state it
3654 // was meant to mutate; keep the durable child state but discard partial bytes.
3755 s.escapeBuf = s.escapeBuf[:0]
@@ -39,34 +57,6 @@ func (s *Screen) Resize(width, height int) {
3957 s.fullRedraw()
4058 }
4159
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-
7060 // cloneFrame produces an independent copy in canonical layout: it copies the
7161 // source's logical rows (via Row) into a fresh frame whose offsets are already
7262 // canonical, so a rotated source is normalized in the clone.
@@ -6,32 +6,50 @@ 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+ // renderer.Frame permits a collapsed geometry. There is no layout to
11+ // preserve until a usable viewport is supplied again.
12+ s.buffer = newBuffer(max(width, 0), max(height, 0))
13+ s.Frame = s.buffer.frame
14+ s.Row, s.Col = 0, 0
15+ s.resetScrollRegion()
16+ s.fullRedraw()
17+ return
18+ }
19+ resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
20+ active := &bufferCursor{row: row, col: col}
21+ var savedPoint *bufferCursor
22+ if saved != nil && saved.saved {
23+ savedPoint = &bufferCursor{row: saved.row, col: saved.col}
24+ }
25+ cursors := []*bufferCursor{active, savedPoint}
26+ evicted := b.resize(width, height, cursors...)
27+ if evict {
28+ for _, line := range evicted {
29+ s.recordEvicted(line)
30+ }
31+ }
32+ if savedPoint != nil {
33+ saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
34+ }
35+ return active.row, clamp(active.col, 0, width-1)
36+ }
10 37
11 if s.alternate != nil {38 if s.alternate != nil {
12- var shift int39+ // Both screen states are reflowed independently, but active and saved
13- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)40+ // cursors for each state are mapped by their single buffer layout pass.
14- s.Row = clamp(s.Row-shift, 0, height-1)41+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
15- s.Col = clamp(s.Col, 0, width-1)42+ state := s.alternate
16- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)43+ if state.buffer == nil {
17- s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)44+ state.buffer = bufferFromFrame(state.frame)
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)
26 }45 }
46+ state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
47+ state.frame = state.buffer.frame
48+ state.scrollTop, state.scrollBottom = 0, height-1
27 } else {49 } else {
28- var shift int50+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
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 }51 }
34-52+ s.Frame = s.buffer.frame
35 // A resize can split an in-flight escape sequence from the terminal state it53 // 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.54 // was meant to mutate; keep the durable child state but discard partial bytes.
37 s.escapeBuf = s.escapeBuf[:0]55 s.escapeBuf = s.escapeBuf[:0]
@@ -39,34 +57,6 @@ func (s *Screen) Resize(width, height int) {
39 s.fullRedraw()57 s.fullRedraw()
40 }58 }
41 59
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 the60 // 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 already61 // 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.62 // 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 +5 -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,7 @@ 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.continueRow(s.Row)
7780 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
7881 s.Col = 0
7982 s.index()
@@ -114,6 +117,7 @@ func (s *Screen) putPrintable(r rune) {
114117 s.Frame.Set(x, s.Row, renderer.BlankCell())
115118 }
116119 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
120+ s.buffer.content(s.Row, s.Col+w)
117121 if w == 2 {
118122 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
119123 }
@@ -188,6 +192,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
188192 for x := x0; x < x1; x++ {
189193 s.Frame.Set(x, y, blank)
190194 }
195+ s.buffer.clear(y, x0, x1)
191196 return x0, x1 - x0
192197 }
193198
@@ -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,7 @@ 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.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})80 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
78 s.Col = 081 s.Col = 0
79 s.index()82 s.index()
@@ -114,6 +117,7 @@ func (s *Screen) putPrintable(r rune) {
114 s.Frame.Set(x, s.Row, renderer.BlankCell())117 s.Frame.Set(x, s.Row, renderer.BlankCell())
115 }118 }
116 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})119 s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
120+ s.buffer.content(s.Row, s.Col+w)
117 if w == 2 {121 if w == 2 {
118 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})122 s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
119 }123 }
@@ -188,6 +192,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
188 for x := x0; x < x1; x++ {192 for x := x0; x < x1; x++ {
189 s.Frame.Set(x, y, blank)193 s.Frame.Set(x, y, blank)
190 }194 }
195+ s.buffer.clear(y, x0, x1)
191 return x0, x1 - x0196 return x0, x1 - x0
192 }197 }
193 198
M pkg/vt/snapshot.go +71 -10
@@ -8,7 +8,10 @@ import (
88 "github.com/bnema/vev/pkg/renderer"
99 )
1010
11-const visibleMagic = "VTV1"
11+const (
12+ visibleMagic = "VTV1"
13+ visibleMagicV2 = "VTV2"
14+)
1215
1316 // DecodeStats describes resources declared by one canonical VT blob.
1417 type DecodeStats struct {
@@ -112,6 +115,23 @@ 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) {
118+ return marshalVisible(frame, nil)
119+}
120+
121+// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
122+// alongside its cells so a restored viewport can subsequently reflow.
123+func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
124+ b := s.buffer
125+ if s.alternate != nil {
126+ b = s.alternate.buffer
127+ }
128+ if b == nil {
129+ return MarshalVisible(s.PrimaryVisibleFrame())
130+ }
131+ return marshalVisible(b.frame, b.boundaries)
132+}
133+
134+func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
115135 if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
116136 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
117137 }
@@ -120,8 +140,17 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
120140 if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
121141 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
122142 }
123- out := make([]byte, 0, 13+bytes)
124- out = append(out, visibleMagic...)
143+ boundaryBytes := 0
144+ magic := visibleMagic
145+ if boundaries != nil {
146+ if len(boundaries) != frame.Height {
147+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
148+ }
149+ boundaryBytes = frame.Height * 5
150+ magic = visibleMagicV2
151+ }
152+ out := make([]byte, 0, 13+bytes+uint64(boundaryBytes))
153+ out = append(out, magic...)
125154 out = append(out, historyVersion)
126155 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
127156 out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))
@@ -133,13 +162,21 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
133162 out = appendHistoryCell(out, cell)
134163 }
135164 }
165+ for _, boundary := range boundaries {
166+ out = binary.BigEndian.AppendUint32(out, uint32(boundary.end))
167+ if boundary.soft {
168+ out = append(out, 1)
169+ } else {
170+ out = append(out, 0)
171+ }
172+ }
136173 return out, nil
137174 }
138175
139176 // PreflightVisibleBlob validates dimensions and cell declarations without
140177 // allocating a frame.
141178 func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
142- if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion {
179+ if len(data) < 13 || (string(data[:4]) != visibleMagic && string(data[:4]) != visibleMagicV2) || data[4] != historyVersion {
143180 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
144181 }
145182 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
@@ -148,7 +185,11 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
148185 }
149186 cells := width * height
150187 bytes, ok := historyCellByteCount(cells)
151- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {
188+ expected := bytes
189+ if string(data[:4]) == visibleMagicV2 {
190+ expected += uint64(height) * 5
191+ }
192+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected {
152193 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
153194 }
154195 p := historyParser{data: data[13:]}
@@ -163,19 +204,33 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
163204
164205 // UnmarshalVisible decodes a preflighted exact visible frame.
165206 func UnmarshalVisible(data []byte) (renderer.Frame, error) {
207+ frame, _, err := unmarshalVisible(data)
208+ return frame, err
209+}
210+
211+func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
166212 if _, err := PreflightVisibleBlob(data); err != nil {
167- return renderer.Frame{}, err
213+ return renderer.Frame{}, nil, err
168214 }
169215 width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
170216 frame := renderer.NewFrame(width, height)
171- p := historyParser{data: data[13:]}
217+ cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height))
218+ p := historyParser{data: data[13 : 13+cellBytes]}
172219 for y := range height {
173220 for x := range width {
174221 cell, _ := p.cell()
175222 frame.Set(x, y, cell)
176223 }
177224 }
178- return frame, nil
225+ boundaries := make([]lineBoundary, height)
226+ if string(data[:4]) == visibleMagicV2 {
227+ offset := 13 + int(cellBytes)
228+ for y := range boundaries {
229+ boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1}
230+ offset += 5
231+ }
232+ }
233+ return frame, boundaries, nil
179234 }
180235
181236 // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
@@ -203,17 +258,23 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
203258
204259 // RestorePrimaryVisible replaces the primary frame from an exact visible blob.
205260 func (s *Screen) RestorePrimaryVisible(blob []byte) error {
206- frame, err := UnmarshalVisible(blob)
261+ frame, boundaries, err := unmarshalVisible(blob)
207262 if err != nil {
208263 return err
209264 }
265+ b := bufferFromFrame(frame)
266+ if len(boundaries) == frame.Height {
267+ b.boundaries = boundaries
268+ }
210269 // A persisted primary frame is authoritative: restoring it directly keeps
211270 // exact row widths and height even when a collapsed layout chose a smaller
212271 // initial PTY size. A subsequent resize reconciles it with live geometry.
213272 if s.alternate != nil {
214273 s.alternate.frame = frame
274+ s.alternate.buffer = b
215275 } else {
216- s.Frame = frame
276+ s.buffer = b
277+ s.Frame = b.frame
217278 }
218279 s.fullRedraw()
219280 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 = "VTV1"
13+ visibleMagicV2 = "VTV2"
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,6 +115,23 @@ 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) {
118+ return marshalVisible(frame, nil)
119+}
120+
121+// MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries
122+// alongside its cells so a restored viewport can subsequently reflow.
123+func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
124+ b := s.buffer
125+ if s.alternate != nil {
126+ b = s.alternate.buffer
127+ }
128+ if b == nil {
129+ return MarshalVisible(s.PrimaryVisibleFrame())
130+ }
131+ return marshalVisible(b.frame, b.boundaries)
132+}
133+
134+func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
115 if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {135 if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
116 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)136 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
117 }137 }
@@ -120,8 +140,17 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
120 if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {140 if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
121 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)141 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
122 }142 }
123- out := make([]byte, 0, 13+bytes)143+ boundaryBytes := 0
124- out = append(out, visibleMagic...)144+ magic := visibleMagic
145+ if boundaries != nil {
146+ if len(boundaries) != frame.Height {
147+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
148+ }
149+ boundaryBytes = frame.Height * 5
150+ magic = visibleMagicV2
151+ }
152+ out := make([]byte, 0, 13+bytes+uint64(boundaryBytes))
153+ out = append(out, magic...)
125 out = append(out, historyVersion)154 out = append(out, historyVersion)
126 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))155 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
127 out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))156 out = binary.BigEndian.AppendUint32(out, uint32(frame.Height))
@@ -133,13 +162,21 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) {
133 out = appendHistoryCell(out, cell)162 out = appendHistoryCell(out, cell)
134 }163 }
135 }164 }
165+ for _, boundary := range boundaries {
166+ out = binary.BigEndian.AppendUint32(out, uint32(boundary.end))
167+ if boundary.soft {
168+ out = append(out, 1)
169+ } else {
170+ out = append(out, 0)
171+ }
172+ }
136 return out, nil173 return out, nil
137 }174 }
138 175
139 // PreflightVisibleBlob validates dimensions and cell declarations without176 // PreflightVisibleBlob validates dimensions and cell declarations without
140 // allocating a frame.177 // allocating a frame.
141 func PreflightVisibleBlob(data []byte) (DecodeStats, error) {178 func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
142- if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion {179+ if len(data) < 13 || (string(data[:4]) != visibleMagic && string(data[:4]) != visibleMagicV2) || data[4] != historyVersion {
143 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)180 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
144 }181 }
145 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))182 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
@@ -148,7 +185,11 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
148 }185 }
149 cells := width * height186 cells := width * height
150 bytes, ok := historyCellByteCount(cells)187 bytes, ok := historyCellByteCount(cells)
151- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes {188+ expected := bytes
189+ if string(data[:4]) == visibleMagicV2 {
190+ expected += uint64(height) * 5
191+ }
192+ if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected {
152 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)193 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
153 }194 }
154 p := historyParser{data: data[13:]}195 p := historyParser{data: data[13:]}
@@ -163,19 +204,33 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
163 204
164 // UnmarshalVisible decodes a preflighted exact visible frame.205 // UnmarshalVisible decodes a preflighted exact visible frame.
165 func UnmarshalVisible(data []byte) (renderer.Frame, error) {206 func UnmarshalVisible(data []byte) (renderer.Frame, error) {
207+ frame, _, err := unmarshalVisible(data)
208+ return frame, err
209+}
210+
211+func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) {
166 if _, err := PreflightVisibleBlob(data); err != nil {212 if _, err := PreflightVisibleBlob(data); err != nil {
167- return renderer.Frame{}, err213+ return renderer.Frame{}, nil, err
168 }214 }
169 width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))215 width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13]))
170 frame := renderer.NewFrame(width, height)216 frame := renderer.NewFrame(width, height)
171- p := historyParser{data: data[13:]}217+ cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height))
218+ p := historyParser{data: data[13 : 13+cellBytes]}
172 for y := range height {219 for y := range height {
173 for x := range width {220 for x := range width {
174 cell, _ := p.cell()221 cell, _ := p.cell()
175 frame.Set(x, y, cell)222 frame.Set(x, y, cell)
176 }223 }
177 }224 }
178- return frame, nil225+ boundaries := make([]lineBoundary, height)
226+ if string(data[:4]) == visibleMagicV2 {
227+ offset := 13 + int(cellBytes)
228+ for y := range boundaries {
229+ boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1}
230+ offset += 5
231+ }
232+ }
233+ return frame, boundaries, nil
179 }234 }
180 235
181 // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.236 // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame.
@@ -203,17 +258,23 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell {
203 258
204 // RestorePrimaryVisible replaces the primary frame from an exact visible blob.259 // RestorePrimaryVisible replaces the primary frame from an exact visible blob.
205 func (s *Screen) RestorePrimaryVisible(blob []byte) error {260 func (s *Screen) RestorePrimaryVisible(blob []byte) error {
206- frame, err := UnmarshalVisible(blob)261+ frame, boundaries, err := unmarshalVisible(blob)
207 if err != nil {262 if err != nil {
208 return err263 return err
209 }264 }
265+ b := bufferFromFrame(frame)
266+ if len(boundaries) == frame.Height {
267+ b.boundaries = boundaries
268+ }
210 // A persisted primary frame is authoritative: restoring it directly keeps269 // A persisted primary frame is authoritative: restoring it directly keeps
211 // exact row widths and height even when a collapsed layout chose a smaller270 // exact row widths and height even when a collapsed layout chose a smaller
212 // initial PTY size. A subsequent resize reconciles it with live geometry.271 // initial PTY size. A subsequent resize reconciles it with live geometry.
213 if s.alternate != nil {272 if s.alternate != nil {
214 s.alternate.frame = frame273 s.alternate.frame = frame
274+ s.alternate.buffer = b
215 } else {275 } else {
216- s.Frame = frame276+ s.buffer = b
277+ s.Frame = b.frame
217 }278 }
218 s.fullRedraw()279 s.fullRedraw()
219 return nil280 return nil
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