fix(vt): reflow visible lines on resize
- commit
- 56e46d303cf73a46a0c7a65d09fbc88c4df9f431
- author
- brice · · 2026-07-15 15:30 UTC
- parent
- cb4ef6c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +497 -78
M internal/usecase/daemon/snapshot.go +16 -15
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 13 | "github.com/bnema/vev/pkg/vt" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| @@ -44,12 +43,13 @@ type snapshotCaptureTab struct { | |||
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | 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 | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | const ( | |
| @@ -463,11 +463,13 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh | |||
| 463 | 463 | if pty != nil { | |
| 464 | 464 | pid = pty.Pid() | |
| 465 | 465 | } | |
| 466 | + | visible, visibleErr := p.screen.MarshalPrimaryVisible() | |
| 466 | 467 | 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, | |
| 471 | 473 | } | |
| 472 | 474 | p.mu.Unlock() | |
| 473 | 475 | paneCapture.cwd = fallbackCwd | |
| @@ -502,11 +504,10 @@ func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) | |||
| 502 | 504 | paneIDs := make(map[layout.PaneID]struct{}, len(tabCapture.panes)) | |
| 503 | 505 | for _, paneCapture := range tabCapture.panes { | |
| 504 | 506 | 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) | |
| 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 | 511 | paneIDs[paneCapture.id] = struct{}{} | |
| 511 | 512 | } | |
| 512 | 513 | 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.PaneID | 46 | + | id layout.PaneID |
| 48 | - | stableID string | 47 | + | stableID string |
| 49 | - | cwd string | 48 | + | cwd string |
| 50 | - | history vt.HistoryView | 49 | + | history vt.HistoryView |
| 51 | - | visible renderer.Frame | 50 | + | visible []byte |
| 52 | - | process *snapcodec.Process | 51 | + | 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 = fallbackCwd | 475 | 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 { | |||
| 51 | 51 | OnClipboard func(b64 string) | |
| 52 | 52 | ||
| 53 | 53 | history *History | |
| 54 | + | buffer *buffer | |
| 54 | 55 | ||
| 55 | 56 | defaultFG renderer.RGB | |
| 56 | 57 | defaultBG renderer.RGB | |
| @@ -85,8 +86,10 @@ type Screen struct { | |||
| 85 | 86 | } | |
| 86 | 87 | ||
| 87 | 88 | func NewScreen(width, height int) *Screen { | |
| 89 | + | b := newBuffer(width, height) | |
| 88 | 90 | s := &Screen{ | |
| 89 | - | Frame: renderer.NewFrame(width, height), | |
| 91 | + | Frame: b.frame, | |
| 92 | + | buffer: b, | |
| 90 | 93 | Style: renderer.DefaultStyle(), | |
| 91 | 94 | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 92 | 95 | damageGeneration: 1, | |
| @@ -165,6 +168,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||
| 165 | 168 | s.restoreCursor() | |
| 166 | 169 | return 2, false | |
| 167 | 170 | case 'D': | |
| 171 | + | s.buffer.hard(s.Row) | |
| 168 | 172 | s.index() | |
| 169 | 173 | return 2, false | |
| 170 | 174 | case 'E': | |
| @@ -51,6 +51,7 @@ type Screen struct { | |||||
| 51 | OnClipboard func(b64 string) | 51 | OnClipboard func(b64 string) | ||
| 52 | 52 | ||||
| 53 | history *History | 53 | history *History | ||
| 54 | + | buffer *buffer | |||
| 54 | 55 | ||||
| 55 | defaultFG renderer.RGB | 56 | defaultFG renderer.RGB | ||
| 56 | defaultBG renderer.RGB | 57 | 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, false | 169 | return 2, false | ||
| 167 | case 'D': | 170 | case 'D': | ||
| 171 | + | s.buffer.hard(s.Row) | |||
| 168 | s.index() | 172 | s.index() | ||
| 169 | return 2, false | 173 | 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) { | |||
| 408 | 408 | for i := range s.Frame.Cells { | |
| 409 | 409 | 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 | 414 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |
| 412 | 415 | default: | |
| 413 | 416 | 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] = blank | 409 | 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" | |||
| 4 | 4 | ||
| 5 | 5 | type screenState struct { | |
| 6 | 6 | frame renderer.Frame | |
| 7 | + | buffer *buffer | |
| 7 | 8 | row int | |
| 8 | 9 | col int | |
| 9 | 10 | style renderer.Style | |
| @@ -86,7 +87,8 @@ func (s *Screen) colorSchemeReport() []byte { | |||
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 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 | 92 | s.Row, s.Col = 0, 0 | |
| 91 | 93 | s.Style = renderer.DefaultStyle() | |
| 92 | 94 | s.escapeBuf = s.escapeBuf[:0] | |
| @@ -190,6 +192,7 @@ func (s *Screen) enterAlternateScreen() { | |||
| 190 | 192 | if s.alternate == nil { | |
| 191 | 193 | s.alternate = &screenState{ | |
| 192 | 194 | frame: cloneFrame(s.Frame), | |
| 195 | + | buffer: s.buffer.clone(), | |
| 193 | 196 | row: s.Row, | |
| 194 | 197 | col: s.Col, | |
| 195 | 198 | style: s.Style, | |
| @@ -200,7 +203,8 @@ func (s *Screen) enterAlternateScreen() { | |||
| 200 | 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 | 208 | s.Row, s.Col = 0, 0 | |
| 205 | 209 | s.Style = renderer.DefaultStyle() | |
| 206 | 210 | s.savedCursor = cursorState{} | |
| @@ -213,7 +217,11 @@ func (s *Screen) exitAlternateScreen() { | |||
| 213 | 217 | return | |
| 214 | 218 | } | |
| 215 | 219 | 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 | 225 | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |
| 218 | 226 | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |
| 219 | 227 | 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.Frame | 6 | frame renderer.Frame | ||
| 7 | + | buffer *buffer | |||
| 7 | row int | 8 | row int | ||
| 8 | col int | 9 | col int | ||
| 9 | style renderer.Style | 10 | 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, 0 | 92 | 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, 0 | 208 | 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 | return | 217 | return | ||
| 214 | } | 218 | } | ||
| 215 | state := s.alternate | 219 | 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.style | 227 | s.Style = state.style | ||
M pkg/vt/screen_resize.go +39 -49
| @@ -6,32 +6,50 @@ func (s *Screen) Resize(width, height int) { | |||
| 6 | 6 | if width == s.Frame.Width && height == s.Frame.Height { | |
| 7 | 7 | return | |
| 8 | 8 | } | |
| 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 | + | } | |
| 10 | 37 | ||
| 11 | 38 | 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) | |
| 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 | 49 | } 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) | |
| 33 | 51 | } | |
| 34 | - | ||
| 52 | + | s.Frame = s.buffer.frame | |
| 35 | 53 | // A resize can split an in-flight escape sequence from the terminal state it | |
| 36 | 54 | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 37 | 55 | s.escapeBuf = s.escapeBuf[:0] | |
| @@ -39,34 +57,6 @@ func (s *Screen) Resize(width, height int) { | |||
| 39 | 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 | 60 | // cloneFrame produces an independent copy in canonical layout: it copies the | |
| 71 | 61 | // source's logical rows (via Row) into a fresh frame whose offsets are already | |
| 72 | 62 | // 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 | return | 7 | return | ||
| 8 | } | 8 | } | ||
| 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 | + | } | |||
| 10 | 37 | ||||
| 11 | if s.alternate != nil { | 38 | if s.alternate != nil { | ||
| 12 | - | var shift int | 39 | + | // 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 int | 50 | + | 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 it | 53 | // 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 the | 60 | // 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 already | 61 | // 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) { | |||
| 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 | 155 | name: "width truncates and pads without reflow", | |
| 108 | 156 | 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() { | |||
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | func (s *Screen) nextLine() { | |
| 21 | + | s.buffer.hard(s.Row) | |
| 21 | 22 | s.Col = 0 | |
| 22 | 23 | s.index() | |
| 23 | 24 | } | |
| @@ -69,6 +70,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 69 | 70 | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| 70 | 71 | s.emitLineEvicted(top, n) | |
| 71 | 72 | s.Frame.ScrollUp(top, bottom, n) | |
| 73 | + | s.buffer.scrollUp(top, bottom, n) | |
| 72 | 74 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 73 | 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 | 111 | // Full-width region: rotate line offsets instead of copying cells. | |
| 110 | 112 | s.Frame.ScrollDown(top, bottom, n) | |
| 113 | + | s.buffer.scrollDown(top, bottom, n) | |
| 111 | 114 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 112 | 115 | } | |
| 113 | 116 | ||
| @@ -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 = 0 | 22 | 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) { | |||
| 17 | 17 | if s.Frame.Width > 0 && s.Col >= s.Frame.Width { | |
| 18 | 18 | s.Col = s.Frame.Width - 1 | |
| 19 | 19 | } | |
| 20 | + | s.buffer.hard(s.Row) | |
| 20 | 21 | s.index() | |
| 21 | 22 | case '\b': | |
| 22 | 23 | if s.Col > 0 { | |
| @@ -50,6 +51,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 50 | 51 | } | |
| 51 | 52 | // Deferred wrap: cursor sits past the last column. | |
| 52 | 53 | if s.Col >= s.Frame.Width { | |
| 54 | + | s.buffer.soft(s.Row) | |
| 53 | 55 | s.Col = 0 | |
| 54 | 56 | s.index() | |
| 55 | 57 | } | |
| @@ -74,6 +76,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 74 | 76 | } | |
| 75 | 77 | s.clearWidePairAt(s.Col, s.Row) | |
| 76 | 78 | s.Frame.Set(s.Col, s.Row, renderer.BlankCell()) | |
| 79 | + | s.buffer.continueRow(s.Row) | |
| 77 | 80 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | |
| 78 | 81 | s.Col = 0 | |
| 79 | 82 | s.index() | |
| @@ -114,6 +117,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 114 | 117 | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |
| 115 | 118 | } | |
| 116 | 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 | 121 | if w == 2 { | |
| 118 | 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 | 192 | for x := x0; x < x1; x++ { | |
| 189 | 193 | s.Frame.Set(x, y, blank) | |
| 190 | 194 | } | |
| 195 | + | s.buffer.clear(y, x0, x1) | |
| 191 | 196 | return x0, x1 - x0 | |
| 192 | 197 | } | |
| 193 | 198 | ||
| @@ -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 - 1 | 18 | 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 = 0 | 55 | 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 = 0 | 81 | 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 - x0 | 196 | return x0, x1 - x0 | ||
| 192 | } | 197 | } | ||
| 193 | 198 | ||||
M pkg/vt/snapshot.go +71 -10
| @@ -8,7 +8,10 @@ import ( | |||
| 8 | 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 | 16 | // DecodeStats describes resources declared by one canonical VT blob. | |
| 14 | 17 | type DecodeStats struct { | |
| @@ -112,6 +115,23 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale | |||
| 112 | 115 | // MarshalVisible encodes the exact visible primary frame, including every | |
| 113 | 116 | // blank, row width, and terminal cell attribute. | |
| 114 | 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 | 135 | if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 { | |
| 116 | 136 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 117 | 137 | } | |
| @@ -120,8 +140,17 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 120 | 140 | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | |
| 121 | 141 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 122 | 142 | } | |
| 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...) | |
| 125 | 154 | out = append(out, historyVersion) | |
| 126 | 155 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| 127 | 156 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |
| @@ -133,13 +162,21 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 133 | 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 | 173 | return out, nil | |
| 137 | 174 | } | |
| 138 | 175 | ||
| 139 | 176 | // PreflightVisibleBlob validates dimensions and cell declarations without | |
| 140 | 177 | // allocating a frame. | |
| 141 | 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 | 180 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 144 | 181 | } | |
| 145 | 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 | 186 | cells := width * height | |
| 150 | 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 | 193 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 153 | 194 | } | |
| 154 | 195 | p := historyParser{data: data[13:]} | |
| @@ -163,19 +204,33 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 163 | 204 | ||
| 164 | 205 | // UnmarshalVisible decodes a preflighted exact visible frame. | |
| 165 | 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 | 212 | if _, err := PreflightVisibleBlob(data); err != nil { | |
| 167 | - | return renderer.Frame{}, err | |
| 213 | + | return renderer.Frame{}, nil, err | |
| 168 | 214 | } | |
| 169 | 215 | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |
| 170 | 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 | 219 | for y := range height { | |
| 173 | 220 | for x := range width { | |
| 174 | 221 | cell, _ := p.cell() | |
| 175 | 222 | frame.Set(x, y, cell) | |
| 176 | 223 | } | |
| 177 | 224 | } | |
| 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 | |
| 179 | 234 | } | |
| 180 | 235 | ||
| 181 | 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 | 259 | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |
| 205 | 260 | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |
| 206 | - | frame, err := UnmarshalVisible(blob) | |
| 261 | + | frame, boundaries, err := unmarshalVisible(blob) | |
| 207 | 262 | if err != nil { | |
| 208 | 263 | return err | |
| 209 | 264 | } | |
| 265 | + | b := bufferFromFrame(frame) | |
| 266 | + | if len(boundaries) == frame.Height { | |
| 267 | + | b.boundaries = boundaries | |
| 268 | + | } | |
| 210 | 269 | // A persisted primary frame is authoritative: restoring it directly keeps | |
| 211 | 270 | // exact row widths and height even when a collapsed layout chose a smaller | |
| 212 | 271 | // initial PTY size. A subsequent resize reconciles it with live geometry. | |
| 213 | 272 | if s.alternate != nil { | |
| 214 | 273 | s.alternate.frame = frame | |
| 274 | + | s.alternate.buffer = b | |
| 215 | 275 | } else { | |
| 216 | - | s.Frame = frame | |
| 276 | + | s.buffer = b | |
| 277 | + | s.Frame = b.frame | |
| 217 | 278 | } | |
| 218 | 279 | s.fullRedraw() | |
| 219 | 280 | 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 every | 115 | // 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, nil | 173 | return out, nil | ||
| 137 | } | 174 | } | ||
| 138 | 175 | ||||
| 139 | // PreflightVisibleBlob validates dimensions and cell declarations without | 176 | // 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 * height | 186 | 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{}, err | 213 | + | 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, 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 | |||
| 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 err | 263 | 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 keeps | 269 | // A persisted primary frame is authoritative: restoring it directly keeps | ||
| 211 | // exact row widths and height even when a collapsed layout chose a smaller | 270 | // 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 = frame | 273 | s.alternate.frame = frame | ||
| 274 | + | s.alternate.buffer = b | |||
| 215 | } else { | 275 | } else { | ||
| 216 | - | s.Frame = frame | 276 | + | s.buffer = b |
| 277 | + | s.Frame = b.frame | |||
| 217 | } | 278 | } | ||
| 218 | s.fullRedraw() | 279 | s.fullRedraw() | ||
| 219 | return nil | 280 | return nil | ||
M pkg/vt/snapshot_test.go +14 -0
| @@ -7,6 +7,20 @@ import ( | |||
| 7 | 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 | 24 | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |
| 11 | 25 | s := NewScreen(6, 2) | |
| 12 | 26 | 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")) | ||