Merge pull request #98 from bnema/fix/viewport-resize-reflow
fix(vt): reflow visible lines on terminal resize
19 files · +879 -89
M internal/usecase/daemon/output_state_stream_test.go +30 -0
| @@ -77,6 +77,36 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra | |||
| 77 | 77 | return stream.frame(data, reset, echoAck), true, nil | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | // A resize is a state reset, but it must not turn subsequent idle or damaged | |
| 81 | + | // renders into full frames. | |
| 82 | + | func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) { | |
| 83 | + | screen := vt.NewScreen(4, 2) | |
| 84 | + | screen.Write([]byte("abcd")) | |
| 85 | + | screen.ClearDamage() | |
| 86 | + | screen.Resize(6, 2) | |
| 87 | + | ||
| 88 | + | stream := newOutputStateStream() | |
| 89 | + | resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | require.True(t, ok) | |
| 92 | + | resizeOutput, err := ports.UnmarshalOutput(resized.Payload) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame") | |
| 95 | + | screen.ClearDamage() | |
| 96 | + | ||
| 97 | + | _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.False(t, ok, "an unchanged render after resize must emit nothing") | |
| 100 | + | ||
| 101 | + | screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 102 | + | damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | require.True(t, ok) | |
| 105 | + | damageOutput, err := ports.UnmarshalOutput(damaged.Payload) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental") | |
| 108 | + | } | |
| 109 | + | ||
| 80 | 110 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | |
| 81 | 111 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | |
| 82 | 112 | require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding) | |
| @@ -77,6 +77,36 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra | |||||
| 77 | return stream.frame(data, reset, echoAck), true, nil | 77 | return stream.frame(data, reset, echoAck), true, nil | ||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | + | // A resize is a state reset, but it must not turn subsequent idle or damaged | |||
| 81 | + | // renders into full frames. | |||
| 82 | + | func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) { | |||
| 83 | + | screen := vt.NewScreen(4, 2) | |||
| 84 | + | screen.Write([]byte("abcd")) | |||
| 85 | + | screen.ClearDamage() | |||
| 86 | + | screen.Resize(6, 2) | |||
| 87 | + | ||||
| 88 | + | stream := newOutputStateStream() | |||
| 89 | + | resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | require.True(t, ok) | |||
| 92 | + | resizeOutput, err := ports.UnmarshalOutput(resized.Payload) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame") | |||
| 95 | + | screen.ClearDamage() | |||
| 96 | + | ||||
| 97 | + | _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.False(t, ok, "an unchanged render after resize must emit nothing") | |||
| 100 | + | ||||
| 101 | + | screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 102 | + | damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | require.True(t, ok) | |||
| 105 | + | damageOutput, err := ports.UnmarshalOutput(damaged.Payload) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental") | |||
| 108 | + | } | |||
| 109 | + | ||||
| 80 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | 110 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | ||
| 81 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | 111 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | ||
| 82 | require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding) | 112 | require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding) | ||
M internal/usecase/daemon/snapshot.go +16 -15
| @@ -10,7 +10,6 @@ import ( | |||
| 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 { | ||
M internal/usecase/daemon/transactional_resize_test.go +3 -0
| @@ -357,6 +357,9 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi | |||
| 357 | 357 | // through S2 immediately (subject to ACK/sync gates). | |
| 358 | 358 | frame := <-frames | |
| 359 | 359 | require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame") | |
| 360 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 361 | + | require.NoError(t, err) | |
| 362 | + | require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state") | |
| 360 | 363 | select { | |
| 361 | 364 | case extra := <-frames: | |
| 362 | 365 | t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra) | |
| @@ -357,6 +357,9 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi | |||||
| 357 | // through S2 immediately (subject to ACK/sync gates). | 357 | // through S2 immediately (subject to ACK/sync gates). | ||
| 358 | frame := <-frames | 358 | frame := <-frames | ||
| 359 | require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame") | 359 | require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame") | ||
| 360 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 361 | + | require.NoError(t, err) | |||
| 362 | + | require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state") | |||
| 360 | select { | 363 | select { | ||
| 361 | case extra := <-frames: | 364 | case extra := <-frames: | ||
| 362 | t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra) | 365 | t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra) | ||
M pkg/vt/benchmark_test.go +33 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||
| 159 | 160 | return history | |
| 160 | 161 | } | |
| 161 | 162 | ||
| 163 | + | // BenchmarkScreenResizeReflowViewport verifies resize only visits the live | |
| 164 | + | // viewport. The 10K-history variant must track the control rather than history | |
| 165 | + | // length; history is deliberately populated without changing the viewport. | |
| 166 | + | func BenchmarkScreenResizeReflowViewport(b *testing.B) { | |
| 167 | + | b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) }) | |
| 168 | + | b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) }) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |
| 172 | + | b.Helper() | |
| 173 | + | s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500}) | |
| 174 | + | if historyRows > 0 { | |
| 175 | + | for range historyRows { | |
| 176 | + | s.history.Append(make([]renderer.Cell, 120)) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | s.Write(bytes.Repeat([]byte("x"), 120*39)) | |
| 180 | + | s.ClearDamage() | |
| 181 | + | sizes := [2][2]int{{80, 40}, {120, 40}} | |
| 182 | + | ||
| 183 | + | b.ReportAllocs() | |
| 184 | + | b.ResetTimer() | |
| 185 | + | for i := 0; b.Loop(); i++ { | |
| 186 | + | s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]) | |
| 187 | + | s.ClearDamage() | |
| 188 | + | } | |
| 189 | + | b.StopTimer() | |
| 190 | + | if got := s.History(); historyRows > 0 && got.Len() < historyRows { | |
| 191 | + | b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 162 | 195 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | |
| 163 | 196 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | |
| 164 | 197 | b.ReportAllocs() | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 7 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -159,6 +160,38 @@ func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||||
| 159 | return history | 160 | return history | ||
| 160 | } | 161 | } | ||
| 161 | 162 | ||||
| 163 | + | // BenchmarkScreenResizeReflowViewport verifies resize only visits the live | |||
| 164 | + | // viewport. The 10K-history variant must track the control rather than history | |||
| 165 | + | // length; history is deliberately populated without changing the viewport. | |||
| 166 | + | func BenchmarkScreenResizeReflowViewport(b *testing.B) { | |||
| 167 | + | b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) }) | |||
| 168 | + | b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) }) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |||
| 172 | + | b.Helper() | |||
| 173 | + | s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500}) | |||
| 174 | + | if historyRows > 0 { | |||
| 175 | + | for range historyRows { | |||
| 176 | + | s.history.Append(make([]renderer.Cell, 120)) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | s.Write(bytes.Repeat([]byte("x"), 120*39)) | |||
| 180 | + | s.ClearDamage() | |||
| 181 | + | sizes := [2][2]int{{80, 40}, {120, 40}} | |||
| 182 | + | ||||
| 183 | + | b.ReportAllocs() | |||
| 184 | + | b.ResetTimer() | |||
| 185 | + | for i := 0; b.Loop(); i++ { | |||
| 186 | + | s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]) | |||
| 187 | + | s.ClearDamage() | |||
| 188 | + | } | |||
| 189 | + | b.StopTimer() | |||
| 190 | + | if got := s.History(); historyRows > 0 && got.Len() < historyRows { | |||
| 191 | + | b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 162 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | 195 | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | ||
| 163 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | 196 | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | ||
| 164 | b.ReportAllocs() | 197 | b.ReportAllocs() | ||
A pkg/vt/buffer.go +367 -0
| @@ -0,0 +1,367 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // buffer owns the visible VT grid and the physical-row boundaries needed to | |
| 6 | + | // reconstruct logical lines. History deliberately is not part of this type: | |
| 7 | + | // reflow is bounded by the live grid. | |
| 8 | + | type buffer struct { | |
| 9 | + | frame renderer.Frame | |
| 10 | + | boundaries []lineBoundary | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | type lineBoundary struct { | |
| 14 | + | // end is the meaningful cell extent. It excludes padding introduced when a | |
| 15 | + | // wide rune was moved off the right edge. | |
| 16 | + | end int | |
| 17 | + | soft bool // the row continues into the following physical row | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func newBuffer(width, height int) *buffer { | |
| 21 | + | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]lineBoundary, height)} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func bufferFromFrame(frame renderer.Frame) *buffer { | |
| 25 | + | return &buffer{frame: frame, boundaries: make([]lineBoundary, frame.Height)} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (b *buffer) clone() *buffer { | |
| 29 | + | out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]lineBoundary(nil), b.boundaries...)} | |
| 30 | + | return out | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (b *buffer) content(y, end int) { | |
| 34 | + | if y < 0 || y >= len(b.boundaries) { | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (b *buffer) truncate(y, end int) { | |
| 41 | + | if y < 0 || y >= len(b.boundaries) { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width)) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // insert retains the meaningful shifted tail when insertion happens within it. | |
| 48 | + | func (b *buffer) insert(y, at, width int) { | |
| 49 | + | if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (b *buffer) hard(y int) { | |
| 56 | + | if y >= 0 && y < len(b.boundaries) { | |
| 57 | + | b.boundaries[y].soft = false | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (b *buffer) soft(y int) { | |
| 62 | + | if y >= 0 && y < len(b.boundaries) { | |
| 63 | + | b.boundaries[y].end = b.frame.Width | |
| 64 | + | b.boundaries[y].soft = true | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (b *buffer) continueRow(y int) { | |
| 69 | + | if y >= 0 && y < len(b.boundaries) { | |
| 70 | + | b.boundaries[y].soft = true | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (b *buffer) clear(y, x0, x1 int) { | |
| 75 | + | if y < 0 || y >= len(b.boundaries) { | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | b.content(y, x1) | |
| 79 | + | if x0 == 0 && x1 >= b.frame.Width { | |
| 80 | + | b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft} | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (b *buffer) scrollUp(top, bottom, n int) { | |
| 85 | + | // A region operation changes which rows meet at both region edges. A soft | |
| 86 | + | // boundary belongs to the row it follows, not the cells moved into its | |
| 87 | + | // neighbor, so sever links crossing either edge before reflow can observe | |
| 88 | + | // them. | |
| 89 | + | b.hard(top - 1) | |
| 90 | + | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | |
| 91 | + | for y := bottom - n + 1; y <= bottom; y++ { | |
| 92 | + | b.boundaries[y] = lineBoundary{} | |
| 93 | + | } | |
| 94 | + | b.hard(bottom - n) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (b *buffer) scrollDown(top, bottom, n int) { | |
| 98 | + | // See scrollUp: the old top no longer follows the row above the region, and | |
| 99 | + | // the last moved row no longer precedes the row below it. | |
| 100 | + | b.hard(top - 1) | |
| 101 | + | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | |
| 102 | + | for y := top; y < top+n; y++ { | |
| 103 | + | b.boundaries[y] = lineBoundary{} | |
| 104 | + | } | |
| 105 | + | b.hard(bottom) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (b *buffer) hydrate() { | |
| 109 | + | for y := range b.boundaries { | |
| 110 | + | if b.boundaries[y].end != 0 { | |
| 111 | + | continue | |
| 112 | + | } | |
| 113 | + | for x := b.frame.Width - 1; x >= 0; x-- { | |
| 114 | + | if !b.frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 115 | + | b.boundaries[y].end = x + 1 | |
| 116 | + | break | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | type bufferCursor struct{ row, col int } | |
| 123 | + | ||
| 124 | + | func (b *buffer) hasSoft() bool { | |
| 125 | + | for _, boundary := range b.boundaries { | |
| 126 | + | if boundary.soft { | |
| 127 | + | return true | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return false | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // resizeFixed keeps hard physical lines independent. It is the common shell | |
| 134 | + | // path and avoids constructing logical-line scratch state when nothing wraps. | |
| 135 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |
| 136 | + | anchor := 0 | |
| 137 | + | if active != nil { | |
| 138 | + | anchor = active.row | |
| 139 | + | } | |
| 140 | + | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |
| 141 | + | evicted := make([][]renderer.Cell, 0, shift) | |
| 142 | + | for y := range shift { | |
| 143 | + | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |
| 144 | + | } | |
| 145 | + | // Fixed-line resizes can retain the boundary backing store. In particular, | |
| 146 | + | // keep its capacity through a short viewport so the common shrink/grow | |
| 147 | + | // sequence does not add metadata allocation to every resize epoch. | |
| 148 | + | boundaries := b.boundaries | |
| 149 | + | if cap(boundaries) < height { | |
| 150 | + | boundaries = make([]lineBoundary, height) | |
| 151 | + | } else { | |
| 152 | + | boundaries = boundaries[:height] | |
| 153 | + | } | |
| 154 | + | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries} | |
| 155 | + | copied := 0 | |
| 156 | + | for y := range height { | |
| 157 | + | sy := y + shift | |
| 158 | + | if sy >= b.frame.Height { | |
| 159 | + | break | |
| 160 | + | } | |
| 161 | + | copy(next.frame.Row(y), b.frame.Row(sy)) | |
| 162 | + | next.boundaries[y] = b.boundaries[sy] | |
| 163 | + | next.boundaries[y].end = min(next.boundaries[y].end, width) | |
| 164 | + | repairFrameRow(next.frame, y) | |
| 165 | + | copied++ | |
| 166 | + | } | |
| 167 | + | clear(next.boundaries[copied:]) | |
| 168 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |
| 169 | + | if cur != nil { | |
| 170 | + | cur.row = clamp(cur.row-shift, 0, height-1) | |
| 171 | + | cur.col = clamp(cur.col, 0, width) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | *b = next | |
| 175 | + | return evicted | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | type reflowPoint struct { | |
| 179 | + | line, offset int | |
| 180 | + | row, col int | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // cursorReflowPoints maps source cursor positions to offsets in their logical | |
| 184 | + | // lines. There are exactly two callers (active and DECSC), so this stays flat | |
| 185 | + | // and stack allocated rather than building a row/column lookup map. | |
| 186 | + | func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint { | |
| 187 | + | points := [2]reflowPoint{{line: -1}, {line: -1}} | |
| 188 | + | cursors := [2]*bufferCursor{active, saved} | |
| 189 | + | for start := 0; start < b.frame.Height; { | |
| 190 | + | end := start | |
| 191 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |
| 192 | + | end++ | |
| 193 | + | } | |
| 194 | + | for i, cur := range cursors { | |
| 195 | + | if cur == nil || cur.row < start || cur.row > end { | |
| 196 | + | continue | |
| 197 | + | } | |
| 198 | + | offset := 0 | |
| 199 | + | for y := start; y < cur.row; y++ { | |
| 200 | + | offset += b.boundaries[y].end | |
| 201 | + | } | |
| 202 | + | points[i] = reflowPoint{ | |
| 203 | + | line: start, | |
| 204 | + | offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end), | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | start = end + 1 | |
| 208 | + | } | |
| 209 | + | return points | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // resize lays out only the current grid. It does two direct, bounded passes: | |
| 213 | + | // the first maps both cursors and counts rows; the second writes only the | |
| 214 | + | // retained viewport (and the rows genuinely evicted to history). It never | |
| 215 | + | // materializes logical lines, per-cell position maps, or temporary output rows. | |
| 216 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |
| 217 | + | if !b.hasSoft() { | |
| 218 | + | return b.resizeFixed(width, height, active, saved) | |
| 219 | + | } | |
| 220 | + | // Only reflow needs meaningful extents; hard physical rows are copied as | |
| 221 | + | // cells, so scanning blank rows on the shell fast path is wasted work. | |
| 222 | + | b.hydrate() | |
| 223 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |
| 224 | + | if cur != nil { | |
| 225 | + | b.content(cur.row, cur.col) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | points := b.cursorReflowPoints(active, saved) | |
| 230 | + | rows := b.layoutReflow(width, &points, nil, 0, nil) | |
| 231 | + | anchor := 0 | |
| 232 | + | if active != nil { | |
| 233 | + | anchor = points[0].row | |
| 234 | + | } | |
| 235 | + | shift := clamp(anchor-(height-1), 0, max(rows-height, 0)) | |
| 236 | + | ||
| 237 | + | next := newBuffer(width, height) | |
| 238 | + | var evicted [][]renderer.Cell | |
| 239 | + | var evictedCells []renderer.Cell | |
| 240 | + | if shift > 0 { | |
| 241 | + | // History needs owned rows. One flat backing store replaces a temporary | |
| 242 | + | // allocation per evicted output row. | |
| 243 | + | evicted = make([][]renderer.Cell, shift) | |
| 244 | + | evictedCells = make([]renderer.Cell, shift*width) | |
| 245 | + | for y := range evicted { | |
| 246 | + | evicted[y] = evictedCells[y*width : (y+1)*width] | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | b.layoutReflow(width, &points, next, shift, evictedCells) | |
| 250 | + | for i, cur := range [2]*bufferCursor{active, saved} { | |
| 251 | + | if cur != nil { | |
| 252 | + | cur.row = clamp(points[i].row-shift, 0, height-1) | |
| 253 | + | cur.col = clamp(points[i].col, 0, width) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | *b = *next | |
| 257 | + | return evicted | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| 261 | + | // [shift, shift+dst.height). Rows before shift are written straight into the | |
| 262 | + | // contiguous eviction backing store. The return is the logical output height. | |
| 263 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int { | |
| 264 | + | row, col := 0, 0 | |
| 265 | + | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 266 | + | if outputRow < shift { | |
| 267 | + | out := evicted[outputRow*width : (outputRow+1)*width] | |
| 268 | + | for i := range out { | |
| 269 | + | out[i] = renderer.BlankCell() | |
| 270 | + | } | |
| 271 | + | return out | |
| 272 | + | } | |
| 273 | + | if dst != nil && outputRow < shift+dst.frame.Height { | |
| 274 | + | return dst.frame.Row(outputRow - shift) | |
| 275 | + | } | |
| 276 | + | return nil | |
| 277 | + | } | |
| 278 | + | var output []renderer.Cell | |
| 279 | + | if dst != nil && (shift > 0 || dst.frame.Height > 0) { | |
| 280 | + | output = blankEvicted(0) | |
| 281 | + | } | |
| 282 | + | finishRow := func(soft bool) { | |
| 283 | + | if dst != nil && row >= shift && row < shift+dst.frame.Height { | |
| 284 | + | dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft} | |
| 285 | + | } | |
| 286 | + | row++ | |
| 287 | + | col = 0 | |
| 288 | + | if dst != nil { | |
| 289 | + | output = blankEvicted(row) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | setPoint := func(line, offset, pointRow, pointCol int) { | |
| 293 | + | for i := range points { | |
| 294 | + | if points[i].line == line && points[i].offset == offset { | |
| 295 | + | points[i].row, points[i].col = pointRow, pointCol | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | setRemainingPoints := func(line, offset, pointRow, pointCol int) { | |
| 300 | + | for i := range points { | |
| 301 | + | if points[i].line == line && points[i].offset >= offset { | |
| 302 | + | points[i].row, points[i].col = pointRow, pointCol | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | for start := 0; start < b.frame.Height; { | |
| 308 | + | end := start | |
| 309 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |
| 310 | + | end++ | |
| 311 | + | } | |
| 312 | + | reflow := end > start | |
| 313 | + | offset := 0 | |
| 314 | + | truncated := false | |
| 315 | + | for y := start; y <= end && !truncated; y++ { | |
| 316 | + | cells := b.frame.Row(y) | |
| 317 | + | limit := b.boundaries[y].end | |
| 318 | + | for x := 0; x < limit; { | |
| 319 | + | cell := cells[x] | |
| 320 | + | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | |
| 321 | + | setPoint(start, offset, row, col) | |
| 322 | + | x++ | |
| 323 | + | offset++ | |
| 324 | + | continue | |
| 325 | + | } | |
| 326 | + | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation | |
| 327 | + | w := 1 | |
| 328 | + | if wide && width >= 2 { | |
| 329 | + | w = 2 | |
| 330 | + | } | |
| 331 | + | if col+w > width && col > 0 { | |
| 332 | + | if !reflow { | |
| 333 | + | setRemainingPoints(start, offset, row, col) | |
| 334 | + | truncated = true | |
| 335 | + | break | |
| 336 | + | } | |
| 337 | + | finishRow(true) | |
| 338 | + | } | |
| 339 | + | setPoint(start, offset, row, col) | |
| 340 | + | if wide && width >= 2 { | |
| 341 | + | if output != nil { | |
| 342 | + | output[col], output[col+1] = cell, cells[x+1] | |
| 343 | + | } | |
| 344 | + | setPoint(start, offset+1, row, col+1) | |
| 345 | + | col += 2 | |
| 346 | + | x += 2 | |
| 347 | + | offset += 2 | |
| 348 | + | continue | |
| 349 | + | } | |
| 350 | + | if wide { | |
| 351 | + | cell.Rune = '\uFFFD' | |
| 352 | + | cell.Continuation = false | |
| 353 | + | } | |
| 354 | + | if output != nil { | |
| 355 | + | output[col] = cell | |
| 356 | + | } | |
| 357 | + | col++ | |
| 358 | + | x++ | |
| 359 | + | offset++ | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | setPoint(start, offset, row, col) | |
| 363 | + | finishRow(false) | |
| 364 | + | start = end + 1 | |
| 365 | + | } | |
| 366 | + | return row | |
| 367 | + | } | |
| @@ -0,0 +1,367 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // buffer owns the visible VT grid and the physical-row boundaries needed to | |||
| 6 | + | // reconstruct logical lines. History deliberately is not part of this type: | |||
| 7 | + | // reflow is bounded by the live grid. | |||
| 8 | + | type buffer struct { | |||
| 9 | + | frame renderer.Frame | |||
| 10 | + | boundaries []lineBoundary | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | type lineBoundary struct { | |||
| 14 | + | // end is the meaningful cell extent. It excludes padding introduced when a | |||
| 15 | + | // wide rune was moved off the right edge. | |||
| 16 | + | end int | |||
| 17 | + | soft bool // the row continues into the following physical row | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func newBuffer(width, height int) *buffer { | |||
| 21 | + | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]lineBoundary, height)} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func bufferFromFrame(frame renderer.Frame) *buffer { | |||
| 25 | + | return &buffer{frame: frame, boundaries: make([]lineBoundary, frame.Height)} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (b *buffer) clone() *buffer { | |||
| 29 | + | out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]lineBoundary(nil), b.boundaries...)} | |||
| 30 | + | return out | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (b *buffer) content(y, end int) { | |||
| 34 | + | if y < 0 || y >= len(b.boundaries) { | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (b *buffer) truncate(y, end int) { | |||
| 41 | + | if y < 0 || y >= len(b.boundaries) { | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // insert retains the meaningful shifted tail when insertion happens within it. | |||
| 48 | + | func (b *buffer) insert(y, at, width int) { | |||
| 49 | + | if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (b *buffer) hard(y int) { | |||
| 56 | + | if y >= 0 && y < len(b.boundaries) { | |||
| 57 | + | b.boundaries[y].soft = false | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (b *buffer) soft(y int) { | |||
| 62 | + | if y >= 0 && y < len(b.boundaries) { | |||
| 63 | + | b.boundaries[y].end = b.frame.Width | |||
| 64 | + | b.boundaries[y].soft = true | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (b *buffer) continueRow(y int) { | |||
| 69 | + | if y >= 0 && y < len(b.boundaries) { | |||
| 70 | + | b.boundaries[y].soft = true | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (b *buffer) clear(y, x0, x1 int) { | |||
| 75 | + | if y < 0 || y >= len(b.boundaries) { | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | b.content(y, x1) | |||
| 79 | + | if x0 == 0 && x1 >= b.frame.Width { | |||
| 80 | + | b.boundaries[y] = lineBoundary{soft: b.boundaries[y].soft} | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (b *buffer) scrollUp(top, bottom, n int) { | |||
| 85 | + | // A region operation changes which rows meet at both region edges. A soft | |||
| 86 | + | // boundary belongs to the row it follows, not the cells moved into its | |||
| 87 | + | // neighbor, so sever links crossing either edge before reflow can observe | |||
| 88 | + | // them. | |||
| 89 | + | b.hard(top - 1) | |||
| 90 | + | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | |||
| 91 | + | for y := bottom - n + 1; y <= bottom; y++ { | |||
| 92 | + | b.boundaries[y] = lineBoundary{} | |||
| 93 | + | } | |||
| 94 | + | b.hard(bottom - n) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (b *buffer) scrollDown(top, bottom, n int) { | |||
| 98 | + | // See scrollUp: the old top no longer follows the row above the region, and | |||
| 99 | + | // the last moved row no longer precedes the row below it. | |||
| 100 | + | b.hard(top - 1) | |||
| 101 | + | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | |||
| 102 | + | for y := top; y < top+n; y++ { | |||
| 103 | + | b.boundaries[y] = lineBoundary{} | |||
| 104 | + | } | |||
| 105 | + | b.hard(bottom) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (b *buffer) hydrate() { | |||
| 109 | + | for y := range b.boundaries { | |||
| 110 | + | if b.boundaries[y].end != 0 { | |||
| 111 | + | continue | |||
| 112 | + | } | |||
| 113 | + | for x := b.frame.Width - 1; x >= 0; x-- { | |||
| 114 | + | if !b.frame.At(x, y).Equal(renderer.BlankCell()) { | |||
| 115 | + | b.boundaries[y].end = x + 1 | |||
| 116 | + | break | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | type bufferCursor struct{ row, col int } | |||
| 123 | + | ||||
| 124 | + | func (b *buffer) hasSoft() bool { | |||
| 125 | + | for _, boundary := range b.boundaries { | |||
| 126 | + | if boundary.soft { | |||
| 127 | + | return true | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return false | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // resizeFixed keeps hard physical lines independent. It is the common shell | |||
| 134 | + | // path and avoids constructing logical-line scratch state when nothing wraps. | |||
| 135 | + | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |||
| 136 | + | anchor := 0 | |||
| 137 | + | if active != nil { | |||
| 138 | + | anchor = active.row | |||
| 139 | + | } | |||
| 140 | + | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |||
| 141 | + | evicted := make([][]renderer.Cell, 0, shift) | |||
| 142 | + | for y := range shift { | |||
| 143 | + | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |||
| 144 | + | } | |||
| 145 | + | // Fixed-line resizes can retain the boundary backing store. In particular, | |||
| 146 | + | // keep its capacity through a short viewport so the common shrink/grow | |||
| 147 | + | // sequence does not add metadata allocation to every resize epoch. | |||
| 148 | + | boundaries := b.boundaries | |||
| 149 | + | if cap(boundaries) < height { | |||
| 150 | + | boundaries = make([]lineBoundary, height) | |||
| 151 | + | } else { | |||
| 152 | + | boundaries = boundaries[:height] | |||
| 153 | + | } | |||
| 154 | + | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries} | |||
| 155 | + | copied := 0 | |||
| 156 | + | for y := range height { | |||
| 157 | + | sy := y + shift | |||
| 158 | + | if sy >= b.frame.Height { | |||
| 159 | + | break | |||
| 160 | + | } | |||
| 161 | + | copy(next.frame.Row(y), b.frame.Row(sy)) | |||
| 162 | + | next.boundaries[y] = b.boundaries[sy] | |||
| 163 | + | next.boundaries[y].end = min(next.boundaries[y].end, width) | |||
| 164 | + | repairFrameRow(next.frame, y) | |||
| 165 | + | copied++ | |||
| 166 | + | } | |||
| 167 | + | clear(next.boundaries[copied:]) | |||
| 168 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |||
| 169 | + | if cur != nil { | |||
| 170 | + | cur.row = clamp(cur.row-shift, 0, height-1) | |||
| 171 | + | cur.col = clamp(cur.col, 0, width) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | *b = next | |||
| 175 | + | return evicted | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | type reflowPoint struct { | |||
| 179 | + | line, offset int | |||
| 180 | + | row, col int | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // cursorReflowPoints maps source cursor positions to offsets in their logical | |||
| 184 | + | // lines. There are exactly two callers (active and DECSC), so this stays flat | |||
| 185 | + | // and stack allocated rather than building a row/column lookup map. | |||
| 186 | + | func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint { | |||
| 187 | + | points := [2]reflowPoint{{line: -1}, {line: -1}} | |||
| 188 | + | cursors := [2]*bufferCursor{active, saved} | |||
| 189 | + | for start := 0; start < b.frame.Height; { | |||
| 190 | + | end := start | |||
| 191 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |||
| 192 | + | end++ | |||
| 193 | + | } | |||
| 194 | + | for i, cur := range cursors { | |||
| 195 | + | if cur == nil || cur.row < start || cur.row > end { | |||
| 196 | + | continue | |||
| 197 | + | } | |||
| 198 | + | offset := 0 | |||
| 199 | + | for y := start; y < cur.row; y++ { | |||
| 200 | + | offset += b.boundaries[y].end | |||
| 201 | + | } | |||
| 202 | + | points[i] = reflowPoint{ | |||
| 203 | + | line: start, | |||
| 204 | + | offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].end), | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | start = end + 1 | |||
| 208 | + | } | |||
| 209 | + | return points | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // resize lays out only the current grid. It does two direct, bounded passes: | |||
| 213 | + | // the first maps both cursors and counts rows; the second writes only the | |||
| 214 | + | // retained viewport (and the rows genuinely evicted to history). It never | |||
| 215 | + | // materializes logical lines, per-cell position maps, or temporary output rows. | |||
| 216 | + | func (b *buffer) resize(width, height int, active, saved *bufferCursor) [][]renderer.Cell { | |||
| 217 | + | if !b.hasSoft() { | |||
| 218 | + | return b.resizeFixed(width, height, active, saved) | |||
| 219 | + | } | |||
| 220 | + | // Only reflow needs meaningful extents; hard physical rows are copied as | |||
| 221 | + | // cells, so scanning blank rows on the shell fast path is wasted work. | |||
| 222 | + | b.hydrate() | |||
| 223 | + | for _, cur := range [2]*bufferCursor{active, saved} { | |||
| 224 | + | if cur != nil { | |||
| 225 | + | b.content(cur.row, cur.col) | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | points := b.cursorReflowPoints(active, saved) | |||
| 230 | + | rows := b.layoutReflow(width, &points, nil, 0, nil) | |||
| 231 | + | anchor := 0 | |||
| 232 | + | if active != nil { | |||
| 233 | + | anchor = points[0].row | |||
| 234 | + | } | |||
| 235 | + | shift := clamp(anchor-(height-1), 0, max(rows-height, 0)) | |||
| 236 | + | ||||
| 237 | + | next := newBuffer(width, height) | |||
| 238 | + | var evicted [][]renderer.Cell | |||
| 239 | + | var evictedCells []renderer.Cell | |||
| 240 | + | if shift > 0 { | |||
| 241 | + | // History needs owned rows. One flat backing store replaces a temporary | |||
| 242 | + | // allocation per evicted output row. | |||
| 243 | + | evicted = make([][]renderer.Cell, shift) | |||
| 244 | + | evictedCells = make([]renderer.Cell, shift*width) | |||
| 245 | + | for y := range evicted { | |||
| 246 | + | evicted[y] = evictedCells[y*width : (y+1)*width] | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | b.layoutReflow(width, &points, next, shift, evictedCells) | |||
| 250 | + | for i, cur := range [2]*bufferCursor{active, saved} { | |||
| 251 | + | if cur != nil { | |||
| 252 | + | cur.row = clamp(points[i].row-shift, 0, height-1) | |||
| 253 | + | cur.col = clamp(points[i].col, 0, width) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | *b = *next | |||
| 257 | + | return evicted | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |||
| 261 | + | // [shift, shift+dst.height). Rows before shift are written straight into the | |||
| 262 | + | // contiguous eviction backing store. The return is the logical output height. | |||
| 263 | + | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell) int { | |||
| 264 | + | row, col := 0, 0 | |||
| 265 | + | blankEvicted := func(outputRow int) []renderer.Cell { | |||
| 266 | + | if outputRow < shift { | |||
| 267 | + | out := evicted[outputRow*width : (outputRow+1)*width] | |||
| 268 | + | for i := range out { | |||
| 269 | + | out[i] = renderer.BlankCell() | |||
| 270 | + | } | |||
| 271 | + | return out | |||
| 272 | + | } | |||
| 273 | + | if dst != nil && outputRow < shift+dst.frame.Height { | |||
| 274 | + | return dst.frame.Row(outputRow - shift) | |||
| 275 | + | } | |||
| 276 | + | return nil | |||
| 277 | + | } | |||
| 278 | + | var output []renderer.Cell | |||
| 279 | + | if dst != nil && (shift > 0 || dst.frame.Height > 0) { | |||
| 280 | + | output = blankEvicted(0) | |||
| 281 | + | } | |||
| 282 | + | finishRow := func(soft bool) { | |||
| 283 | + | if dst != nil && row >= shift && row < shift+dst.frame.Height { | |||
| 284 | + | dst.boundaries[row-shift] = lineBoundary{end: col, soft: soft} | |||
| 285 | + | } | |||
| 286 | + | row++ | |||
| 287 | + | col = 0 | |||
| 288 | + | if dst != nil { | |||
| 289 | + | output = blankEvicted(row) | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | setPoint := func(line, offset, pointRow, pointCol int) { | |||
| 293 | + | for i := range points { | |||
| 294 | + | if points[i].line == line && points[i].offset == offset { | |||
| 295 | + | points[i].row, points[i].col = pointRow, pointCol | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | setRemainingPoints := func(line, offset, pointRow, pointCol int) { | |||
| 300 | + | for i := range points { | |||
| 301 | + | if points[i].line == line && points[i].offset >= offset { | |||
| 302 | + | points[i].row, points[i].col = pointRow, pointCol | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | for start := 0; start < b.frame.Height; { | |||
| 308 | + | end := start | |||
| 309 | + | for b.boundaries[end].soft && end+1 < b.frame.Height { | |||
| 310 | + | end++ | |||
| 311 | + | } | |||
| 312 | + | reflow := end > start | |||
| 313 | + | offset := 0 | |||
| 314 | + | truncated := false | |||
| 315 | + | for y := start; y <= end && !truncated; y++ { | |||
| 316 | + | cells := b.frame.Row(y) | |||
| 317 | + | limit := b.boundaries[y].end | |||
| 318 | + | for x := 0; x < limit; { | |||
| 319 | + | cell := cells[x] | |||
| 320 | + | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | |||
| 321 | + | setPoint(start, offset, row, col) | |||
| 322 | + | x++ | |||
| 323 | + | offset++ | |||
| 324 | + | continue | |||
| 325 | + | } | |||
| 326 | + | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation | |||
| 327 | + | w := 1 | |||
| 328 | + | if wide && width >= 2 { | |||
| 329 | + | w = 2 | |||
| 330 | + | } | |||
| 331 | + | if col+w > width && col > 0 { | |||
| 332 | + | if !reflow { | |||
| 333 | + | setRemainingPoints(start, offset, row, col) | |||
| 334 | + | truncated = true | |||
| 335 | + | break | |||
| 336 | + | } | |||
| 337 | + | finishRow(true) | |||
| 338 | + | } | |||
| 339 | + | setPoint(start, offset, row, col) | |||
| 340 | + | if wide && width >= 2 { | |||
| 341 | + | if output != nil { | |||
| 342 | + | output[col], output[col+1] = cell, cells[x+1] | |||
| 343 | + | } | |||
| 344 | + | setPoint(start, offset+1, row, col+1) | |||
| 345 | + | col += 2 | |||
| 346 | + | x += 2 | |||
| 347 | + | offset += 2 | |||
| 348 | + | continue | |||
| 349 | + | } | |||
| 350 | + | if wide { | |||
| 351 | + | cell.Rune = '\uFFFD' | |||
| 352 | + | cell.Continuation = false | |||
| 353 | + | } | |||
| 354 | + | if output != nil { | |||
| 355 | + | output[col] = cell | |||
| 356 | + | } | |||
| 357 | + | col++ | |||
| 358 | + | x++ | |||
| 359 | + | offset++ | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | setPoint(start, offset, row, col) | |||
| 363 | + | finishRow(false) | |||
| 364 | + | start = end + 1 | |||
| 365 | + | } | |||
| 366 | + | return row | |||
| 367 | + | } | |||
A pkg/vt/buffer_cursor_reflow_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestResizeReflowMapsActiveAndSavedCursorAtNewRowBoundary(t *testing.T) { | |
| 6 | + | s := NewScreen(5, 3) | |
| 7 | + | s.Write([]byte("abcdefgh")) | |
| 8 | + | s.Row, s.Col = 0, 3 | |
| 9 | + | s.saveCursor() | |
| 10 | + | ||
| 11 | + | s.Resize(3, 3) | |
| 12 | + | ||
| 13 | + | if s.Row != 1 || s.Col != 0 { | |
| 14 | + | t.Fatalf("active cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |
| 15 | + | } | |
| 16 | + | s.Row, s.Col = 0, 0 | |
| 17 | + | s.restoreCursor() | |
| 18 | + | if s.Row != 1 || s.Col != 0 { | |
| 19 | + | t.Fatalf("saved cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |
| 20 | + | } | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestResizeReflowMapsActiveAndSavedCursorAtNewRowBoundary(t *testing.T) { | |||
| 6 | + | s := NewScreen(5, 3) | |||
| 7 | + | s.Write([]byte("abcdefgh")) | |||
| 8 | + | s.Row, s.Col = 0, 3 | |||
| 9 | + | s.saveCursor() | |||
| 10 | + | ||||
| 11 | + | s.Resize(3, 3) | |||
| 12 | + | ||||
| 13 | + | if s.Row != 1 || s.Col != 0 { | |||
| 14 | + | t.Fatalf("active cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 15 | + | } | |||
| 16 | + | s.Row, s.Col = 0, 0 | |||
| 17 | + | s.restoreCursor() | |||
| 18 | + | if s.Row != 1 || s.Col != 0 { | |||
| 19 | + | t.Fatalf("saved cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 20 | + | } | |||
| 21 | + | } | |||
M pkg/vt/history_codec.go +3 -2
| @@ -24,8 +24,9 @@ const ( | |||
| 24 | 24 | maxHistoryRowCells = 160 | |
| 25 | 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 26 | 26 | ||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 27 | + | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 29 | + | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |
| 29 | 30 | ) | |
| 30 | 31 | ||
| 31 | 32 | var errInvalidHistory = errors.New("invalid history payload") | |
| @@ -24,8 +24,9 @@ const ( | |||||
| 24 | maxHistoryRowCells = 160 | 24 | maxHistoryRowCells = 160 | ||
| 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | ||
| 26 | 26 | ||||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | 27 | + | maxHistoryDecodeStyles = maxHistoryCells |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | 28 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes |
| 29 | + | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |||
| 29 | ) | 30 | ) | ||
| 30 | 31 | ||||
| 31 | var errInvalidHistory = errors.New("invalid history payload") | 32 | var errInvalidHistory = errors.New("invalid history payload") | ||
A pkg/vt/region_reflow_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | seq string | |
| 13 | + | want []string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "CSI S", | |
| 17 | + | seq: "\x1b[2;4r\x1b[S", | |
| 18 | + | want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"}, | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "CSI T", | |
| 22 | + | seq: "\x1b[2;4r\x1b[T", | |
| 23 | + | want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"}, | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "insert lines", | |
| 27 | + | seq: "\x1b[2;4r\x1b[2;1H\x1b[L", | |
| 28 | + | want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"}, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "delete lines", | |
| 32 | + | seq: "\x1b[2;4r\x1b[2;1H\x1b[M", | |
| 33 | + | want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"}, | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | for _, tt := range tests { | |
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | s := NewScreen(4, 5) | |
| 40 | + | s.Write([]byte("ABCDEFGHIJKLMNOPQRST")) | |
| 41 | + | s.Write([]byte(tt.seq)) | |
| 42 | + | ||
| 43 | + | s.Resize(3, 6) | |
| 44 | + | ||
| 45 | + | got := make([]string, s.Frame.Height) | |
| 46 | + | for y := range s.Frame.Height { | |
| 47 | + | got[y] = rowString(s.Frame.Row(y)) | |
| 48 | + | } | |
| 49 | + | require.Equal(t, tt.want, got) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | seq string | |||
| 13 | + | want []string | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "CSI S", | |||
| 17 | + | seq: "\x1b[2;4r\x1b[S", | |||
| 18 | + | want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"}, | |||
| 19 | + | }, | |||
| 20 | + | { | |||
| 21 | + | name: "CSI T", | |||
| 22 | + | seq: "\x1b[2;4r\x1b[T", | |||
| 23 | + | want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"}, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "insert lines", | |||
| 27 | + | seq: "\x1b[2;4r\x1b[2;1H\x1b[L", | |||
| 28 | + | want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"}, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "delete lines", | |||
| 32 | + | seq: "\x1b[2;4r\x1b[2;1H\x1b[M", | |||
| 33 | + | want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"}, | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | for _, tt := range tests { | |||
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 39 | + | s := NewScreen(4, 5) | |||
| 40 | + | s.Write([]byte("ABCDEFGHIJKLMNOPQRST")) | |||
| 41 | + | s.Write([]byte(tt.seq)) | |||
| 42 | + | ||||
| 43 | + | s.Resize(3, 6) | |||
| 44 | + | ||||
| 45 | + | got := make([]string, s.Frame.Height) | |||
| 46 | + | for y := range s.Frame.Height { | |||
| 47 | + | got[y] = rowString(s.Frame.Row(y)) | |||
| 48 | + | } | |||
| 49 | + | require.Equal(t, tt.want, got) | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | } | |||
A pkg/vt/resize_reflow_regression_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | |
| 11 | + | s := NewScreen(5, 3) | |
| 12 | + | s.Write([]byte("shell")) | |
| 13 | + | s.Write([]byte("\x1b[?1049h")) | |
| 14 | + | s.Write([]byte("\x1b[")) | |
| 15 | + | ||
| 16 | + | s.Resize(0, 0) | |
| 17 | + | ||
| 18 | + | if len(s.escapeBuf) != 0 { | |
| 19 | + | t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf) | |
| 20 | + | } | |
| 21 | + | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |
| 22 | + | t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |
| 23 | + | } | |
| 24 | + | s.Write([]byte("\x1b[?1049l")) | |
| 25 | + | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |
| 26 | + | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) { | |
| 31 | + | s := NewScreen(4, 3) | |
| 32 | + | s.Write([]byte("ab界")) | |
| 33 | + | s.Write([]byte("\x1b[1;4H界x")) | |
| 34 | + | ||
| 35 | + | s.Resize(6, 3) | |
| 36 | + | ||
| 37 | + | assertCell(t, s, 0, 0, 'a') | |
| 38 | + | assertCell(t, s, 1, 0, 'b') | |
| 39 | + | assertCell(t, s, 2, 0, '界') | |
| 40 | + | if !cellAt(s, 3, 0).Continuation { | |
| 41 | + | t.Fatal("reflowed wide rune lost continuation") | |
| 42 | + | } | |
| 43 | + | assertCell(t, s, 4, 0, 'x') | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |
| 47 | + | s := NewScreen(6, 2) | |
| 48 | + | s.Write([]byte("abcd")) | |
| 49 | + | s.Write([]byte("\x1b[1;2H\x1b[4hX")) | |
| 50 | + | // Simulate the subsequent logical continuation that causes the row to be | |
| 51 | + | // reflowed. Its meaningful extent must include the shifted tail. | |
| 52 | + | s.buffer.continueRow(0) | |
| 53 | + | ||
| 54 | + | s.Resize(5, 2) | |
| 55 | + | ||
| 56 | + | for x, r := range []rune("aXbcd") { | |
| 57 | + | assertCell(t, s, x, 0, r) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestVisibleBoundaryMetadataBudget(t *testing.T) { | |
| 62 | + | const height = maxHistoryRows + 1 | |
| 63 | + | if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil { | |
| 64 | + | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |
| 65 | + | } | |
| 66 | + | s := NewScreen(0, height) | |
| 67 | + | if _, err := s.MarshalPrimaryVisible(); err == nil { | |
| 68 | + | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | data := make([]byte, 13+height*visibleBoundaryBytes) | |
| 72 | + | copy(data[:4], visibleMagic) | |
| 73 | + | data[4] = historyVersion | |
| 74 | + | binary.BigEndian.PutUint32(data[9:13], height) | |
| 75 | + | if _, err := PreflightVisibleBlob(data); err == nil { | |
| 76 | + | t.Fatal("preflight accepted boundary metadata beyond its allocation budget") | |
| 77 | + | } | |
| 78 | + | if _, err := UnmarshalVisible(data); err == nil { | |
| 79 | + | t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | |||
| 11 | + | s := NewScreen(5, 3) | |||
| 12 | + | s.Write([]byte("shell")) | |||
| 13 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 14 | + | s.Write([]byte("\x1b[")) | |||
| 15 | + | ||||
| 16 | + | s.Resize(0, 0) | |||
| 17 | + | ||||
| 18 | + | if len(s.escapeBuf) != 0 { | |||
| 19 | + | t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf) | |||
| 20 | + | } | |||
| 21 | + | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |||
| 22 | + | t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |||
| 23 | + | } | |||
| 24 | + | s.Write([]byte("\x1b[?1049l")) | |||
| 25 | + | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |||
| 26 | + | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) { | |||
| 31 | + | s := NewScreen(4, 3) | |||
| 32 | + | s.Write([]byte("ab界")) | |||
| 33 | + | s.Write([]byte("\x1b[1;4H界x")) | |||
| 34 | + | ||||
| 35 | + | s.Resize(6, 3) | |||
| 36 | + | ||||
| 37 | + | assertCell(t, s, 0, 0, 'a') | |||
| 38 | + | assertCell(t, s, 1, 0, 'b') | |||
| 39 | + | assertCell(t, s, 2, 0, '界') | |||
| 40 | + | if !cellAt(s, 3, 0).Continuation { | |||
| 41 | + | t.Fatal("reflowed wide rune lost continuation") | |||
| 42 | + | } | |||
| 43 | + | assertCell(t, s, 4, 0, 'x') | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |||
| 47 | + | s := NewScreen(6, 2) | |||
| 48 | + | s.Write([]byte("abcd")) | |||
| 49 | + | s.Write([]byte("\x1b[1;2H\x1b[4hX")) | |||
| 50 | + | // Simulate the subsequent logical continuation that causes the row to be | |||
| 51 | + | // reflowed. Its meaningful extent must include the shifted tail. | |||
| 52 | + | s.buffer.continueRow(0) | |||
| 53 | + | ||||
| 54 | + | s.Resize(5, 2) | |||
| 55 | + | ||||
| 56 | + | for x, r := range []rune("aXbcd") { | |||
| 57 | + | assertCell(t, s, x, 0, r) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestVisibleBoundaryMetadataBudget(t *testing.T) { | |||
| 62 | + | const height = maxHistoryRows + 1 | |||
| 63 | + | if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil { | |||
| 64 | + | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |||
| 65 | + | } | |||
| 66 | + | s := NewScreen(0, height) | |||
| 67 | + | if _, err := s.MarshalPrimaryVisible(); err == nil { | |||
| 68 | + | t.Fatal("marshal accepted boundary metadata beyond its allocation budget") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | data := make([]byte, 13+height*visibleBoundaryBytes) | |||
| 72 | + | copy(data[:4], visibleMagic) | |||
| 73 | + | data[4] = historyVersion | |||
| 74 | + | binary.BigEndian.PutUint32(data[9:13], height) | |||
| 75 | + | if _, err := PreflightVisibleBlob(data); err == nil { | |||
| 76 | + | t.Fatal("preflight accepted boundary metadata beyond its allocation budget") | |||
| 77 | + | } | |||
| 78 | + | if _, err := UnmarshalVisible(data); err == nil { | |||
| 79 | + | t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | } | |||
M pkg/vt/screen.go +5 -1
| @@ -51,6 +51,7 @@ type Screen struct { | |||
| 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 +44 -51
| @@ -6,32 +6,53 @@ 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 | |
| 10 | - | ||
| 11 | - | if s.alternate != nil { | |
| 12 | - | var shift int | |
| 13 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |
| 14 | - | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 15 | - | s.Col = clamp(s.Col, 0, width-1) | |
| 16 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 17 | - | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |
| 18 | - | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |
| 19 | - | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |
| 20 | - | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |
| 21 | - | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |
| 22 | - | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |
| 23 | - | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |
| 24 | - | s.alternate.scrollTop = 0 | |
| 25 | - | s.alternate.scrollBottom = max(height-1, 0) | |
| 9 | + | if width <= 0 || height <= 0 { | |
| 10 | + | // renderer.Frame permits a collapsed geometry. There is no layout to | |
| 11 | + | // preserve until a usable viewport is supplied again. | |
| 12 | + | width, height = max(width, 0), max(height, 0) | |
| 13 | + | s.buffer = newBuffer(width, height) | |
| 14 | + | s.Row, s.Col = 0, 0 | |
| 15 | + | if state := s.alternate; state != nil { | |
| 16 | + | state.buffer = newBuffer(width, height) | |
| 17 | + | state.frame = state.buffer.frame | |
| 18 | + | state.row, state.col = 0, 0 | |
| 19 | + | state.scrollTop, state.scrollBottom = 0, height-1 | |
| 26 | 20 | } | |
| 27 | 21 | } else { | |
| 28 | - | var shift int | |
| 29 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |
| 30 | - | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 31 | - | s.Col = clamp(s.Col, 0, width-1) | |
| 32 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 33 | - | } | |
| 22 | + | resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) { | |
| 23 | + | active := &bufferCursor{row: row, col: col} | |
| 24 | + | var savedPoint *bufferCursor | |
| 25 | + | if saved != nil && saved.saved { | |
| 26 | + | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | |
| 27 | + | } | |
| 28 | + | evicted := b.resize(width, height, active, savedPoint) | |
| 29 | + | if evict { | |
| 30 | + | for _, line := range evicted { | |
| 31 | + | s.recordEvicted(line) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | if savedPoint != nil { | |
| 35 | + | saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1) | |
| 36 | + | } | |
| 37 | + | return active.row, clamp(active.col, 0, width-1) | |
| 38 | + | } | |
| 34 | 39 | ||
| 40 | + | if s.alternate != nil { | |
| 41 | + | // Both screen states are reflowed independently, but active and saved | |
| 42 | + | // cursors for each state are mapped by their single buffer layout pass. | |
| 43 | + | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false) | |
| 44 | + | state := s.alternate | |
| 45 | + | if state.buffer == nil { | |
| 46 | + | state.buffer = bufferFromFrame(state.frame) | |
| 47 | + | } | |
| 48 | + | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | |
| 49 | + | state.frame = state.buffer.frame | |
| 50 | + | state.scrollTop, state.scrollBottom = 0, height-1 | |
| 51 | + | } else { | |
| 52 | + | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | s.Frame = s.buffer.frame | |
| 35 | 56 | // A resize can split an in-flight escape sequence from the terminal state it | |
| 36 | 57 | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 37 | 58 | s.escapeBuf = s.escapeBuf[:0] | |
| @@ -39,34 +60,6 @@ func (s *Screen) Resize(width, height int) { | |||
| 39 | 60 | s.fullRedraw() | |
| 40 | 61 | } | |
| 41 | 62 | ||
| 42 | - | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |
| 43 | - | next := renderer.NewFrame(newW, newH) | |
| 44 | - | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |
| 45 | - | if evict != nil { | |
| 46 | - | for y := range shift { | |
| 47 | - | evict(old.Row(y)) | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | for dy := range newH { | |
| 51 | - | sy := dy + shift | |
| 52 | - | if sy >= old.Height { | |
| 53 | - | break | |
| 54 | - | } | |
| 55 | - | copy(next.Row(dy), old.Row(sy)) | |
| 56 | - | repairFrameRow(next, dy) | |
| 57 | - | } | |
| 58 | - | return next, shift | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |
| 62 | - | if !cur.saved { | |
| 63 | - | return cur | |
| 64 | - | } | |
| 65 | - | cur.row = clamp(cur.row-shift, 0, height-1) | |
| 66 | - | cur.col = clamp(cur.col, 0, width-1) | |
| 67 | - | return cur | |
| 68 | - | } | |
| 69 | - | ||
| 70 | 63 | // cloneFrame produces an independent copy in canonical layout: it copies the | |
| 71 | 64 | // source's logical rows (via Row) into a fresh frame whose offsets are already | |
| 72 | 65 | // canonical, so a rotated source is normalized in the clone. | |
| @@ -6,32 +6,53 @@ func (s *Screen) Resize(width, height int) { | |||||
| 6 | if width == s.Frame.Width && height == s.Frame.Height { | 6 | if width == s.Frame.Width && height == s.Frame.Height { | ||
| 7 | return | 7 | return | ||
| 8 | } | 8 | } | ||
| 9 | - | evict := s.recordEvicted | 9 | + | if width <= 0 || height <= 0 { |
| 10 | - | 10 | + | // renderer.Frame permits a collapsed geometry. There is no layout to | |
| 11 | - | if s.alternate != nil { | 11 | + | // preserve until a usable viewport is supplied again. |
| 12 | - | var shift int | 12 | + | width, height = max(width, 0), max(height, 0) |
| 13 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | 13 | + | s.buffer = newBuffer(width, height) |
| 14 | - | s.Row = clamp(s.Row-shift, 0, height-1) | 14 | + | s.Row, s.Col = 0, 0 |
| 15 | - | s.Col = clamp(s.Col, 0, width-1) | 15 | + | if state := s.alternate; state != nil { |
| 16 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | 16 | + | state.buffer = newBuffer(width, height) |
| 17 | - | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | 17 | + | state.frame = state.buffer.frame |
| 18 | - | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | 18 | + | state.row, state.col = 0, 0 |
| 19 | - | s.alternate.col = clamp(s.alternate.col, 0, width-1) | 19 | + | state.scrollTop, state.scrollBottom = 0, height-1 |
| 20 | - | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |||
| 21 | - | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |||
| 22 | - | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |||
| 23 | - | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |||
| 24 | - | s.alternate.scrollTop = 0 | |||
| 25 | - | s.alternate.scrollBottom = max(height-1, 0) | |||
| 26 | } | 20 | } | ||
| 27 | } else { | 21 | } else { | ||
| 28 | - | var shift int | 22 | + | resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) { |
| 29 | - | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | 23 | + | active := &bufferCursor{row: row, col: col} |
| 30 | - | s.Row = clamp(s.Row-shift, 0, height-1) | 24 | + | var savedPoint *bufferCursor |
| 31 | - | s.Col = clamp(s.Col, 0, width-1) | 25 | + | if saved != nil && saved.saved { |
| 32 | - | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | 26 | + | savedPoint = &bufferCursor{row: saved.row, col: saved.col} |
| 33 | - | } | 27 | + | } |
| 28 | + | evicted := b.resize(width, height, active, savedPoint) | |||
| 29 | + | if evict { | |||
| 30 | + | for _, line := range evicted { | |||
| 31 | + | s.recordEvicted(line) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | if savedPoint != nil { | |||
| 35 | + | saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1) | |||
| 36 | + | } | |||
| 37 | + | return active.row, clamp(active.col, 0, width-1) | |||
| 38 | + | } | |||
| 34 | 39 | ||||
| 40 | + | if s.alternate != nil { | |||
| 41 | + | // Both screen states are reflowed independently, but active and saved | |||
| 42 | + | // cursors for each state are mapped by their single buffer layout pass. | |||
| 43 | + | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false) | |||
| 44 | + | state := s.alternate | |||
| 45 | + | if state.buffer == nil { | |||
| 46 | + | state.buffer = bufferFromFrame(state.frame) | |||
| 47 | + | } | |||
| 48 | + | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | |||
| 49 | + | state.frame = state.buffer.frame | |||
| 50 | + | state.scrollTop, state.scrollBottom = 0, height-1 | |||
| 51 | + | } else { | |||
| 52 | + | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | s.Frame = s.buffer.frame | |||
| 35 | // A resize can split an in-flight escape sequence from the terminal state it | 56 | // A resize can split an in-flight escape sequence from the terminal state it | ||
| 36 | // was meant to mutate; keep the durable child state but discard partial bytes. | 57 | // was meant to mutate; keep the durable child state but discard partial bytes. | ||
| 37 | s.escapeBuf = s.escapeBuf[:0] | 58 | s.escapeBuf = s.escapeBuf[:0] | ||
| @@ -39,34 +60,6 @@ func (s *Screen) Resize(width, height int) { | |||||
| 39 | s.fullRedraw() | 60 | s.fullRedraw() | ||
| 40 | } | 61 | } | ||
| 41 | 62 | ||||
| 42 | - | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |||
| 43 | - | next := renderer.NewFrame(newW, newH) | |||
| 44 | - | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |||
| 45 | - | if evict != nil { | |||
| 46 | - | for y := range shift { | |||
| 47 | - | evict(old.Row(y)) | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | for dy := range newH { | |||
| 51 | - | sy := dy + shift | |||
| 52 | - | if sy >= old.Height { | |||
| 53 | - | break | |||
| 54 | - | } | |||
| 55 | - | copy(next.Row(dy), old.Row(sy)) | |||
| 56 | - | repairFrameRow(next, dy) | |||
| 57 | - | } | |||
| 58 | - | return next, shift | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |||
| 62 | - | if !cur.saved { | |||
| 63 | - | return cur | |||
| 64 | - | } | |||
| 65 | - | cur.row = clamp(cur.row-shift, 0, height-1) | |||
| 66 | - | cur.col = clamp(cur.col, 0, width-1) | |||
| 67 | - | return cur | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | // cloneFrame produces an independent copy in canonical layout: it copies the | 63 | // 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 | 64 | // source's logical rows (via Row) into a fresh frame whose offsets are already | ||
| 72 | // canonical, so a rotated source is normalized in the clone. | 65 | // canonical, so a rotated source is normalized in the clone. | ||
M pkg/vt/screen_resize_test.go +48 -0
| @@ -103,6 +103,54 @@ func TestResize(t *testing.T) { | |||
| 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 +7 -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,8 @@ 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.truncate(s.Row, cx) | |
| 80 | + | s.buffer.continueRow(s.Row) | |
| 77 | 81 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | |
| 78 | 82 | s.Col = 0 | |
| 79 | 83 | s.index() | |
| @@ -95,6 +99,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 95 | 99 | row[x] = renderer.BlankCell() | |
| 96 | 100 | } | |
| 97 | 101 | s.repairRow(s.Row) | |
| 102 | + | s.buffer.insert(s.Row, s.Col, w) | |
| 98 | 103 | if leftSplit { | |
| 99 | 104 | insertDamageX = s.Col - 1 | |
| 100 | 105 | } | |
| @@ -114,6 +119,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 114 | 119 | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |
| 115 | 120 | } | |
| 116 | 121 | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | |
| 122 | + | s.buffer.content(s.Row, s.Col+w) | |
| 117 | 123 | if w == 2 { | |
| 118 | 124 | s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style}) | |
| 119 | 125 | } | |
| @@ -188,6 +194,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 188 | 194 | for x := x0; x < x1; x++ { | |
| 189 | 195 | s.Frame.Set(x, y, blank) | |
| 190 | 196 | } | |
| 197 | + | s.buffer.clear(y, x0, x1) | |
| 191 | 198 | return x0, x1 - x0 | |
| 192 | 199 | } | |
| 193 | 200 | ||
| @@ -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,8 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 74 | } | 76 | } | ||
| 75 | s.clearWidePairAt(s.Col, s.Row) | 77 | s.clearWidePairAt(s.Col, s.Row) | ||
| 76 | s.Frame.Set(s.Col, s.Row, renderer.BlankCell()) | 78 | s.Frame.Set(s.Col, s.Row, renderer.BlankCell()) | ||
| 79 | + | s.buffer.truncate(s.Row, cx) | |||
| 80 | + | s.buffer.continueRow(s.Row) | |||
| 77 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | 81 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | ||
| 78 | s.Col = 0 | 82 | s.Col = 0 | ||
| 79 | s.index() | 83 | s.index() | ||
| @@ -95,6 +99,7 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 95 | row[x] = renderer.BlankCell() | 99 | row[x] = renderer.BlankCell() | ||
| 96 | } | 100 | } | ||
| 97 | s.repairRow(s.Row) | 101 | s.repairRow(s.Row) | ||
| 102 | + | s.buffer.insert(s.Row, s.Col, w) | |||
| 98 | if leftSplit { | 103 | if leftSplit { | ||
| 99 | insertDamageX = s.Col - 1 | 104 | insertDamageX = s.Col - 1 | ||
| 100 | } | 105 | } | ||
| @@ -114,6 +119,7 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 114 | s.Frame.Set(x, s.Row, renderer.BlankCell()) | 119 | s.Frame.Set(x, s.Row, renderer.BlankCell()) | ||
| 115 | } | 120 | } | ||
| 116 | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | 121 | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | ||
| 122 | + | s.buffer.content(s.Row, s.Col+w) | |||
| 117 | if w == 2 { | 123 | if w == 2 { | ||
| 118 | s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style}) | 124 | s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style}) | ||
| 119 | } | 125 | } | ||
| @@ -188,6 +194,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||||
| 188 | for x := x0; x < x1; x++ { | 194 | for x := x0; x < x1; x++ { | ||
| 189 | s.Frame.Set(x, y, blank) | 195 | s.Frame.Set(x, y, blank) | ||
| 190 | } | 196 | } | ||
| 197 | + | s.buffer.clear(y, x0, x1) | |||
| 191 | return x0, x1 - x0 | 198 | return x0, x1 - x0 | ||
| 192 | } | 199 | } | ||
| 193 | 200 | ||||
M pkg/vt/snapshot.go +94 -17
| @@ -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 = "VTV2" | |
| 13 | + | visibleBoundaryBytes = 5 | |
| 14 | + | ) | |
| 12 | 15 | ||
| 13 | 16 | // DecodeStats describes resources declared by one canonical VT blob. | |
| 14 | 17 | type DecodeStats struct { | |
| @@ -112,15 +115,40 @@ 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) { | |
| 115 | - | if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 { | |
| 118 | + | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { | |
| 116 | 119 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 117 | 120 | } | |
| 118 | - | cells := uint64(frame.Width) * uint64(frame.Height) | |
| 119 | - | bytes, ok := historyCellByteCount(cells) | |
| 120 | - | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | |
| 121 | + | boundaries := make([]lineBoundary, frame.Height) | |
| 122 | + | for y := range boundaries { | |
| 123 | + | for x := frame.Width - 1; x >= 0; x-- { | |
| 124 | + | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 125 | + | boundaries[y].end = x + 1 | |
| 126 | + | break | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return marshalVisible(frame, boundaries) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |
| 134 | + | // alongside its cells so a restored viewport can subsequently reflow. | |
| 135 | + | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |
| 136 | + | b := s.buffer | |
| 137 | + | if s.alternate != nil { | |
| 138 | + | b = s.alternate.buffer | |
| 139 | + | } | |
| 140 | + | if b == nil { | |
| 141 | + | return MarshalVisible(s.PrimaryVisibleFrame()) | |
| 142 | + | } | |
| 143 | + | return marshalVisible(b.frame, b.boundaries) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) { | |
| 147 | + | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |
| 148 | + | if !ok || len(boundaries) != frame.Height { | |
| 121 | 149 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 122 | 150 | } | |
| 123 | - | out := make([]byte, 0, 13+bytes) | |
| 151 | + | out := make([]byte, 0, int(13+bytes+boundaryBytes)) | |
| 124 | 152 | out = append(out, visibleMagic...) | |
| 125 | 153 | out = append(out, historyVersion) | |
| 126 | 154 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| @@ -133,6 +161,14 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 133 | 161 | out = appendHistoryCell(out, cell) | |
| 134 | 162 | } | |
| 135 | 163 | } | |
| 164 | + | for _, boundary := range boundaries { | |
| 165 | + | out = binary.BigEndian.AppendUint32(out, uint32(boundary.end)) | |
| 166 | + | if boundary.soft { | |
| 167 | + | out = append(out, 1) | |
| 168 | + | } else { | |
| 169 | + | out = append(out, 0) | |
| 170 | + | } | |
| 171 | + | } | |
| 136 | 172 | return out, nil | |
| 137 | 173 | } | |
| 138 | 174 | ||
| @@ -143,12 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 143 | 179 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 144 | 180 | } | |
| 145 | 181 | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |
| 146 | - | if width != 0 && height > math.MaxUint64/width { | |
| 147 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 148 | - | } | |
| 149 | - | cells := width * height | |
| 150 | - | bytes, ok := historyCellByteCount(cells) | |
| 151 | - | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes { | |
| 182 | + | cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height) | |
| 183 | + | if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes { | |
| 152 | 184 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 153 | 185 | } | |
| 154 | 186 | p := historyParser{data: data[13:]} | |
| @@ -158,24 +190,65 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 158 | 190 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 159 | 191 | } | |
| 160 | 192 | } | |
| 193 | + | for range height { | |
| 194 | + | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |
| 195 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 196 | + | } | |
| 197 | + | p.data = p.data[5:] | |
| 198 | + | } | |
| 161 | 199 | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |
| 162 | 200 | } | |
| 163 | 201 | ||
| 202 | + | func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |
| 203 | + | if width < 0 || height < 0 { | |
| 204 | + | return 0, 0, 0, false | |
| 205 | + | } | |
| 206 | + | return visibleEncodingBudget64(uint64(width), uint64(height)) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |
| 210 | + | if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes { | |
| 211 | + | return 0, 0, 0, false | |
| 212 | + | } | |
| 213 | + | cells = width * height | |
| 214 | + | cellBytes, ok = historyCellByteCount(cells) | |
| 215 | + | if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes { | |
| 216 | + | return 0, 0, 0, false | |
| 217 | + | } | |
| 218 | + | boundaryBytes = height * visibleBoundaryBytes | |
| 219 | + | if boundaryBytes > maxVisibleBoundaryBytes { | |
| 220 | + | return 0, 0, 0, false | |
| 221 | + | } | |
| 222 | + | return cells, cellBytes, boundaryBytes, true | |
| 223 | + | } | |
| 224 | + | ||
| 164 | 225 | // UnmarshalVisible decodes a preflighted exact visible frame. | |
| 165 | 226 | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |
| 227 | + | frame, _, err := unmarshalVisible(data) | |
| 228 | + | return frame, err | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) { | |
| 166 | 232 | if _, err := PreflightVisibleBlob(data); err != nil { | |
| 167 | - | return renderer.Frame{}, err | |
| 233 | + | return renderer.Frame{}, nil, err | |
| 168 | 234 | } | |
| 169 | 235 | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |
| 170 | 236 | frame := renderer.NewFrame(width, height) | |
| 171 | - | p := historyParser{data: data[13:]} | |
| 237 | + | cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height)) | |
| 238 | + | p := historyParser{data: data[13 : 13+cellBytes]} | |
| 172 | 239 | for y := range height { | |
| 173 | 240 | for x := range width { | |
| 174 | 241 | cell, _ := p.cell() | |
| 175 | 242 | frame.Set(x, y, cell) | |
| 176 | 243 | } | |
| 177 | 244 | } | |
| 178 | - | return frame, nil | |
| 245 | + | boundaries := make([]lineBoundary, height) | |
| 246 | + | offset := 13 + int(cellBytes) | |
| 247 | + | for y := range boundaries { | |
| 248 | + | boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1} | |
| 249 | + | offset += 5 | |
| 250 | + | } | |
| 251 | + | return frame, boundaries, nil | |
| 179 | 252 | } | |
| 180 | 253 | ||
| 181 | 254 | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |
| @@ -203,17 +276,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||
| 203 | 276 | ||
| 204 | 277 | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |
| 205 | 278 | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |
| 206 | - | frame, err := UnmarshalVisible(blob) | |
| 279 | + | frame, boundaries, err := unmarshalVisible(blob) | |
| 207 | 280 | if err != nil { | |
| 208 | 281 | return err | |
| 209 | 282 | } | |
| 283 | + | b := bufferFromFrame(frame) | |
| 284 | + | b.boundaries = boundaries | |
| 210 | 285 | // A persisted primary frame is authoritative: restoring it directly keeps | |
| 211 | 286 | // exact row widths and height even when a collapsed layout chose a smaller | |
| 212 | 287 | // initial PTY size. A subsequent resize reconciles it with live geometry. | |
| 213 | 288 | if s.alternate != nil { | |
| 214 | 289 | s.alternate.frame = frame | |
| 290 | + | s.alternate.buffer = b | |
| 215 | 291 | } else { | |
| 216 | - | s.Frame = frame | |
| 292 | + | s.buffer = b | |
| 293 | + | s.Frame = b.frame | |
| 217 | 294 | } | |
| 218 | 295 | s.fullRedraw() | |
| 219 | 296 | return nil | |
| @@ -8,7 +8,10 @@ import ( | |||||
| 8 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | const visibleMagic = "VTV1" | 11 | + | const ( |
| 12 | + | visibleMagic = "VTV2" | |||
| 13 | + | visibleBoundaryBytes = 5 | |||
| 14 | + | ) | |||
| 12 | 15 | ||||
| 13 | // DecodeStats describes resources declared by one canonical VT blob. | 16 | // DecodeStats describes resources declared by one canonical VT blob. | ||
| 14 | type DecodeStats struct { | 17 | type DecodeStats struct { | ||
| @@ -112,15 +115,40 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale | |||||
| 112 | // MarshalVisible encodes the exact visible primary frame, including 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) { | ||
| 115 | - | if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 { | 118 | + | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { |
| 116 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | 119 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | ||
| 117 | } | 120 | } | ||
| 118 | - | cells := uint64(frame.Width) * uint64(frame.Height) | 121 | + | boundaries := make([]lineBoundary, frame.Height) |
| 119 | - | bytes, ok := historyCellByteCount(cells) | 122 | + | for y := range boundaries { |
| 120 | - | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes { | 123 | + | for x := frame.Width - 1; x >= 0; x-- { |
| 124 | + | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |||
| 125 | + | boundaries[y].end = x + 1 | |||
| 126 | + | break | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return marshalVisible(frame, boundaries) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |||
| 134 | + | // alongside its cells so a restored viewport can subsequently reflow. | |||
| 135 | + | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |||
| 136 | + | b := s.buffer | |||
| 137 | + | if s.alternate != nil { | |||
| 138 | + | b = s.alternate.buffer | |||
| 139 | + | } | |||
| 140 | + | if b == nil { | |||
| 141 | + | return MarshalVisible(s.PrimaryVisibleFrame()) | |||
| 142 | + | } | |||
| 143 | + | return marshalVisible(b.frame, b.boundaries) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) { | |||
| 147 | + | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |||
| 148 | + | if !ok || len(boundaries) != frame.Height { | |||
| 121 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | 149 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | ||
| 122 | } | 150 | } | ||
| 123 | - | out := make([]byte, 0, 13+bytes) | 151 | + | out := make([]byte, 0, int(13+bytes+boundaryBytes)) |
| 124 | out = append(out, visibleMagic...) | 152 | out = append(out, visibleMagic...) | ||
| 125 | out = append(out, historyVersion) | 153 | out = append(out, historyVersion) | ||
| 126 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | 154 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | ||
| @@ -133,6 +161,14 @@ func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||||
| 133 | out = appendHistoryCell(out, cell) | 161 | out = appendHistoryCell(out, cell) | ||
| 134 | } | 162 | } | ||
| 135 | } | 163 | } | ||
| 164 | + | for _, boundary := range boundaries { | |||
| 165 | + | out = binary.BigEndian.AppendUint32(out, uint32(boundary.end)) | |||
| 166 | + | if boundary.soft { | |||
| 167 | + | out = append(out, 1) | |||
| 168 | + | } else { | |||
| 169 | + | out = append(out, 0) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 136 | return out, nil | 172 | return out, nil | ||
| 137 | } | 173 | } | ||
| 138 | 174 | ||||
| @@ -143,12 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||||
| 143 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 179 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | ||
| 144 | } | 180 | } | ||
| 145 | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | 181 | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | ||
| 146 | - | if width != 0 && height > math.MaxUint64/width { | 182 | + | cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height) |
| 147 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 183 | + | if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes { |
| 148 | - | } | |||
| 149 | - | cells := width * height | |||
| 150 | - | bytes, ok := historyCellByteCount(cells) | |||
| 151 | - | if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != bytes { | |||
| 152 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 184 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | ||
| 153 | } | 185 | } | ||
| 154 | p := historyParser{data: data[13:]} | 186 | p := historyParser{data: data[13:]} | ||
| @@ -158,24 +190,65 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||||
| 158 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | 190 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | ||
| 159 | } | 191 | } | ||
| 160 | } | 192 | } | ||
| 193 | + | for range height { | |||
| 194 | + | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |||
| 195 | + | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 196 | + | } | |||
| 197 | + | p.data = p.data[5:] | |||
| 198 | + | } | |||
| 161 | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | 199 | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | ||
| 162 | } | 200 | } | ||
| 163 | 201 | ||||
| 202 | + | func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |||
| 203 | + | if width < 0 || height < 0 { | |||
| 204 | + | return 0, 0, 0, false | |||
| 205 | + | } | |||
| 206 | + | return visibleEncodingBudget64(uint64(width), uint64(height)) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |||
| 210 | + | if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes { | |||
| 211 | + | return 0, 0, 0, false | |||
| 212 | + | } | |||
| 213 | + | cells = width * height | |||
| 214 | + | cellBytes, ok = historyCellByteCount(cells) | |||
| 215 | + | if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes { | |||
| 216 | + | return 0, 0, 0, false | |||
| 217 | + | } | |||
| 218 | + | boundaryBytes = height * visibleBoundaryBytes | |||
| 219 | + | if boundaryBytes > maxVisibleBoundaryBytes { | |||
| 220 | + | return 0, 0, 0, false | |||
| 221 | + | } | |||
| 222 | + | return cells, cellBytes, boundaryBytes, true | |||
| 223 | + | } | |||
| 224 | + | ||||
| 164 | // UnmarshalVisible decodes a preflighted exact visible frame. | 225 | // UnmarshalVisible decodes a preflighted exact visible frame. | ||
| 165 | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | 226 | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | ||
| 227 | + | frame, _, err := unmarshalVisible(data) | |||
| 228 | + | return frame, err | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func unmarshalVisible(data []byte) (renderer.Frame, []lineBoundary, error) { | |||
| 166 | if _, err := PreflightVisibleBlob(data); err != nil { | 232 | if _, err := PreflightVisibleBlob(data); err != nil { | ||
| 167 | - | return renderer.Frame{}, err | 233 | + | return renderer.Frame{}, nil, err |
| 168 | } | 234 | } | ||
| 169 | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | 235 | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | ||
| 170 | frame := renderer.NewFrame(width, height) | 236 | frame := renderer.NewFrame(width, height) | ||
| 171 | - | p := historyParser{data: data[13:]} | 237 | + | cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height)) |
| 238 | + | p := historyParser{data: data[13 : 13+cellBytes]} | |||
| 172 | for y := range height { | 239 | for y := range height { | ||
| 173 | for x := range width { | 240 | for x := range width { | ||
| 174 | cell, _ := p.cell() | 241 | cell, _ := p.cell() | ||
| 175 | frame.Set(x, y, cell) | 242 | frame.Set(x, y, cell) | ||
| 176 | } | 243 | } | ||
| 177 | } | 244 | } | ||
| 178 | - | return frame, nil | 245 | + | boundaries := make([]lineBoundary, height) |
| 246 | + | offset := 13 + int(cellBytes) | |||
| 247 | + | for y := range boundaries { | |||
| 248 | + | boundaries[y] = lineBoundary{end: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), soft: data[offset+4] == 1} | |||
| 249 | + | offset += 5 | |||
| 250 | + | } | |||
| 251 | + | return frame, boundaries, nil | |||
| 179 | } | 252 | } | ||
| 180 | 253 | ||||
| 181 | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | 254 | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | ||
| @@ -203,17 +276,21 @@ func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||||
| 203 | 276 | ||||
| 204 | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | 277 | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | ||
| 205 | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | 278 | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | ||
| 206 | - | frame, err := UnmarshalVisible(blob) | 279 | + | frame, boundaries, err := unmarshalVisible(blob) |
| 207 | if err != nil { | 280 | if err != nil { | ||
| 208 | return err | 281 | return err | ||
| 209 | } | 282 | } | ||
| 283 | + | b := bufferFromFrame(frame) | |||
| 284 | + | b.boundaries = boundaries | |||
| 210 | // A persisted primary frame is authoritative: restoring it directly keeps | 285 | // A persisted primary frame is authoritative: restoring it directly keeps | ||
| 211 | // exact row widths and height even when a collapsed layout chose a smaller | 286 | // exact row widths and height even when a collapsed layout chose a smaller | ||
| 212 | // initial PTY size. A subsequent resize reconciles it with live geometry. | 287 | // initial PTY size. A subsequent resize reconciles it with live geometry. | ||
| 213 | if s.alternate != nil { | 288 | if s.alternate != nil { | ||
| 214 | s.alternate.frame = frame | 289 | s.alternate.frame = frame | ||
| 290 | + | s.alternate.buffer = b | |||
| 215 | } else { | 291 | } else { | ||
| 216 | - | s.Frame = frame | 292 | + | s.buffer = b |
| 293 | + | s.Frame = b.frame | |||
| 217 | } | 294 | } | ||
| 218 | s.fullRedraw() | 295 | s.fullRedraw() | ||
| 219 | return nil | 296 | return nil | ||
A pkg/vt/snapshot_boundary_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestPreflightVisibleRejectsMalformedBoundaries(t *testing.T) { | |
| 11 | + | s := NewScreen(4, 2) | |
| 12 | + | s.Write([]byte("abcdef")) | |
| 13 | + | blob, err := s.MarshalPrimaryVisible() | |
| 14 | + | require.NoError(t, err) | |
| 15 | + | ||
| 16 | + | boundaryOffset := 13 + 2*4*historyCellBytes | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | mutate func([]byte) | |
| 20 | + | }{ | |
| 21 | + | { | |
| 22 | + | name: "boundary end exceeds row width", | |
| 23 | + | mutate: func(data []byte) { | |
| 24 | + | binary.BigEndian.PutUint32(data[boundaryOffset:boundaryOffset+4], 5) | |
| 25 | + | }, | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "soft flag is not canonical", | |
| 29 | + | mutate: func(data []byte) { | |
| 30 | + | data[boundaryOffset+4] = 2 | |
| 31 | + | }, | |
| 32 | + | }, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | data := append([]byte(nil), blob...) | |
| 38 | + | tt.mutate(data) | |
| 39 | + | _, err := PreflightVisibleBlob(data) | |
| 40 | + | require.Error(t, err) | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestPreflightVisibleRejectsMalformedBoundaries(t *testing.T) { | |||
| 11 | + | s := NewScreen(4, 2) | |||
| 12 | + | s.Write([]byte("abcdef")) | |||
| 13 | + | blob, err := s.MarshalPrimaryVisible() | |||
| 14 | + | require.NoError(t, err) | |||
| 15 | + | ||||
| 16 | + | boundaryOffset := 13 + 2*4*historyCellBytes | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | mutate func([]byte) | |||
| 20 | + | }{ | |||
| 21 | + | { | |||
| 22 | + | name: "boundary end exceeds row width", | |||
| 23 | + | mutate: func(data []byte) { | |||
| 24 | + | binary.BigEndian.PutUint32(data[boundaryOffset:boundaryOffset+4], 5) | |||
| 25 | + | }, | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "soft flag is not canonical", | |||
| 29 | + | mutate: func(data []byte) { | |||
| 30 | + | data[boundaryOffset+4] = 2 | |||
| 31 | + | }, | |||
| 32 | + | }, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | data := append([]byte(nil), blob...) | |||
| 38 | + | tt.mutate(data) | |||
| 39 | + | _, err := PreflightVisibleBlob(data) | |||
| 40 | + | require.Error(t, err) | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
M pkg/vt/snapshot_test.go +14 -0
| @@ -7,6 +7,20 @@ import ( | |||
| 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")) | ||