refactor(vt): remove visible snapshot codec
- commit
- fcb4c06dfae468035285d4067ffe6f95f3f52d40
- author
- brice · · 2026-07-27 12:56 UTC
- parent
- 2dca0c2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +127 -368
M pkg/vt/history_acceptance_test.go +14 -16
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) { | |
| 9 | + | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) { | |
| 10 | 10 | indexed := renderer.Style{ | |
| 11 | 11 | Bold: true, | |
| 12 | 12 | Italic: true, | |
| @@ -79,26 +79,24 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *te | |||
| 79 | 79 | }, | |
| 80 | 80 | }, | |
| 81 | 81 | { | |
| 82 | - | name: "visible frame retains blanks wide heads continuations and styles", | |
| 82 | + | name: "recovery transcript retains blanks wide heads continuations and styles", | |
| 83 | 83 | run: func(t *testing.T) { | |
| 84 | - | want := renderer.NewFrame(4, 2) | |
| 85 | - | copy(want.Row(0), makeRow('D', rgb)) | |
| 86 | - | copy(want.Row(1), makeRow('E', indexed)) | |
| 87 | - | encoded, err := MarshalVisible(want) | |
| 88 | - | if err != nil { | |
| 89 | - | t.Fatalf("MarshalVisible: %v", err) | |
| 84 | + | screen := NewScreen(4, 2) | |
| 85 | + | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} | |
| 86 | + | for y, row := range want { | |
| 87 | + | copy(screen.Frame.Row(y), row) | |
| 88 | + | screen.buffer.boundaries[y] = LineBound{End: len(row)} | |
| 90 | 89 | } | |
| 91 | - | got, err := UnmarshalVisible(encoded) | |
| 90 | + | ||
| 91 | + | encoded, err := screen.RecoveryTranscriptSnapshot().Marshal() | |
| 92 | 92 | if err != nil { | |
| 93 | - | t.Fatalf("UnmarshalVisible: %v", err) | |
| 93 | + | t.Fatalf("marshal recovery transcript: %v", err) | |
| 94 | 94 | } | |
| 95 | - | for y := range want.Height { | |
| 96 | - | for x, cell := range want.Row(y) { | |
| 97 | - | if !got.At(x, y).Equal(cell) { | |
| 98 | - | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell) | |
| 99 | - | } | |
| 100 | - | } | |
| 95 | + | got, err := UnmarshalHistory(encoded) | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatalf("unmarshal recovery transcript: %v", err) | |
| 101 | 98 | } | |
| 99 | + | assertHistoryRowsEqual(t, got, want) | |
| 102 | 100 | }, | |
| 103 | 101 | }, | |
| 104 | 102 | } | |
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) { | 9 | + | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) { |
| 10 | indexed := renderer.Style{ | 10 | indexed := renderer.Style{ | ||
| 11 | Bold: true, | 11 | Bold: true, | ||
| 12 | Italic: true, | 12 | Italic: true, | ||
| @@ -79,26 +79,24 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *te | |||||
| 79 | }, | 79 | }, | ||
| 80 | }, | 80 | }, | ||
| 81 | { | 81 | { | ||
| 82 | - | name: "visible frame retains blanks wide heads continuations and styles", | 82 | + | name: "recovery transcript retains blanks wide heads continuations and styles", |
| 83 | run: func(t *testing.T) { | 83 | run: func(t *testing.T) { | ||
| 84 | - | want := renderer.NewFrame(4, 2) | 84 | + | screen := NewScreen(4, 2) |
| 85 | - | copy(want.Row(0), makeRow('D', rgb)) | 85 | + | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} |
| 86 | - | copy(want.Row(1), makeRow('E', indexed)) | 86 | + | for y, row := range want { |
| 87 | - | encoded, err := MarshalVisible(want) | 87 | + | copy(screen.Frame.Row(y), row) |
| 88 | - | if err != nil { | 88 | + | screen.buffer.boundaries[y] = LineBound{End: len(row)} |
| 89 | - | t.Fatalf("MarshalVisible: %v", err) | |||
| 90 | } | 89 | } | ||
| 91 | - | got, err := UnmarshalVisible(encoded) | 90 | + | |
| 91 | + | encoded, err := screen.RecoveryTranscriptSnapshot().Marshal() | |||
| 92 | if err != nil { | 92 | if err != nil { | ||
| 93 | - | t.Fatalf("UnmarshalVisible: %v", err) | 93 | + | t.Fatalf("marshal recovery transcript: %v", err) |
| 94 | } | 94 | } | ||
| 95 | - | for y := range want.Height { | 95 | + | got, err := UnmarshalHistory(encoded) |
| 96 | - | for x, cell := range want.Row(y) { | 96 | + | if err != nil { |
| 97 | - | if !got.At(x, y).Equal(cell) { | 97 | + | t.Fatalf("unmarshal recovery transcript: %v", err) |
| 98 | - | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell) | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | } | 98 | } | ||
| 99 | + | assertHistoryRowsEqual(t, got, want) | |||
| 102 | }, | 100 | }, | ||
| 103 | }, | 101 | }, | ||
| 104 | } | 102 | } | ||
M pkg/vt/history_codec.go +6 -6
| @@ -14,6 +14,7 @@ const ( | |||
| 14 | 14 | historyMagic = "VTH1" | |
| 15 | 15 | historyVersion = 2 | |
| 16 | 16 | historyCellBytes = 41 | |
| 17 | + | historyBoundBytes = 5 | |
| 17 | 18 | maxHistoryChunkRows = 256 | |
| 18 | 19 | ||
| 19 | 20 | // The daemon retains 10,000 history rows. Support a 20% row margin and | |
| @@ -24,9 +25,8 @@ const ( | |||
| 24 | 25 | maxHistoryRowCells = 160 | |
| 25 | 26 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 26 | 27 | ||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 29 | - | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |
| 28 | + | maxHistoryDecodeStyles = maxHistoryCells | |
| 29 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 30 | 30 | ) | |
| 31 | 31 | ||
| 32 | 32 | var errInvalidHistory = errors.New("invalid history payload") | |
| @@ -206,7 +206,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 206 | 206 | for range chunkCount { | |
| 207 | 207 | rowCount, ok := p.uint32() | |
| 208 | 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |
| 209 | - | uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) || | |
| 209 | + | uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) || | |
| 210 | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 211 | 211 | return HistoryView{}, historyDecodeStats{}, false | |
| 212 | 212 | } | |
| @@ -329,12 +329,12 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 329 | 329 | } | |
| 330 | 330 | ||
| 331 | 331 | func (p *historyParser) bound() (LineBound, bool) { | |
| 332 | - | if len(p.data) < visibleBoundaryBytes { | |
| 332 | + | if len(p.data) < historyBoundBytes { | |
| 333 | 333 | return LineBound{}, false | |
| 334 | 334 | } | |
| 335 | 335 | end := binary.BigEndian.Uint32(p.data[:4]) | |
| 336 | 336 | flag := p.data[4] | |
| 337 | - | p.data = p.data[visibleBoundaryBytes:] | |
| 337 | + | p.data = p.data[historyBoundBytes:] | |
| 338 | 338 | if flag > 1 || uint64(end) > math.MaxInt32 { | |
| 339 | 339 | return LineBound{}, false | |
| 340 | 340 | } | |
| @@ -14,6 +14,7 @@ const ( | |||||
| 14 | historyMagic = "VTH1" | 14 | historyMagic = "VTH1" | ||
| 15 | historyVersion = 2 | 15 | historyVersion = 2 | ||
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | + | historyBoundBytes = 5 | |||
| 17 | maxHistoryChunkRows = 256 | 18 | maxHistoryChunkRows = 256 | ||
| 18 | 19 | ||||
| 19 | // The daemon retains 10,000 history rows. Support a 20% row margin and | 20 | // The daemon retains 10,000 history rows. Support a 20% row margin and | ||
| @@ -24,9 +25,8 @@ const ( | |||||
| 24 | maxHistoryRowCells = 160 | 25 | maxHistoryRowCells = 160 | ||
| 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | 26 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | ||
| 26 | 27 | ||||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | 28 | + | maxHistoryDecodeStyles = maxHistoryCells |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | 29 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes |
| 29 | - | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |||
| 30 | ) | 30 | ) | ||
| 31 | 31 | ||||
| 32 | var errInvalidHistory = errors.New("invalid history payload") | 32 | var errInvalidHistory = errors.New("invalid history payload") | ||
| @@ -206,7 +206,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 206 | for range chunkCount { | 206 | for range chunkCount { | ||
| 207 | rowCount, ok := p.uint32() | 207 | rowCount, ok := p.uint32() | ||
| 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | ||
| 209 | - | uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) || | 209 | + | uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) || |
| 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | ||
| 211 | return HistoryView{}, historyDecodeStats{}, false | 211 | return HistoryView{}, historyDecodeStats{}, false | ||
| 212 | } | 212 | } | ||
| @@ -329,12 +329,12 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||||
| 329 | } | 329 | } | ||
| 330 | 330 | ||||
| 331 | func (p *historyParser) bound() (LineBound, bool) { | 331 | func (p *historyParser) bound() (LineBound, bool) { | ||
| 332 | - | if len(p.data) < visibleBoundaryBytes { | 332 | + | if len(p.data) < historyBoundBytes { |
| 333 | return LineBound{}, false | 333 | return LineBound{}, false | ||
| 334 | } | 334 | } | ||
| 335 | end := binary.BigEndian.Uint32(p.data[:4]) | 335 | end := binary.BigEndian.Uint32(p.data[:4]) | ||
| 336 | flag := p.data[4] | 336 | flag := p.data[4] | ||
| 337 | - | p.data = p.data[visibleBoundaryBytes:] | 337 | + | p.data = p.data[historyBoundBytes:] |
| 338 | if flag > 1 || uint64(end) > math.MaxInt32 { | 338 | if flag > 1 || uint64(end) > math.MaxInt32 { | ||
| 339 | return LineBound{}, false | 339 | return LineBound{}, false | ||
| 340 | } | 340 | } | ||
A pkg/vt/history_codec_bound_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |
| 12 | + | blob, err := MarshalHistory(HistoryView{ | |
| 13 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}}, | |
| 14 | + | rows: 1, | |
| 15 | + | cells: 4, | |
| 16 | + | }) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | boundOffset := len(blob) - historyBoundBytes | |
| 19 | + | ||
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | mutate func([]byte) | |
| 23 | + | }{ | |
| 24 | + | { | |
| 25 | + | name: "end exceeds row width", | |
| 26 | + | mutate: func(data []byte) { | |
| 27 | + | binary.BigEndian.PutUint32(data[boundOffset:boundOffset+4], 5) | |
| 28 | + | }, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "soft flag is not canonical", | |
| 32 | + | mutate: func(data []byte) { | |
| 33 | + | data[boundOffset+4] = 2 | |
| 34 | + | }, | |
| 35 | + | }, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | for _, tt := range tests { | |
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |
| 40 | + | data := append([]byte(nil), blob...) | |
| 41 | + | tt.mutate(data) | |
| 42 | + | ||
| 43 | + | _, preflightErr := PreflightHistoryBlob(data) | |
| 44 | + | require.Error(t, preflightErr) | |
| 45 | + | _, unmarshalErr := UnmarshalHistory(data) | |
| 46 | + | require.Error(t, unmarshalErr) | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |||
| 12 | + | blob, err := MarshalHistory(HistoryView{ | |||
| 13 | + | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}}, | |||
| 14 | + | rows: 1, | |||
| 15 | + | cells: 4, | |||
| 16 | + | }) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | boundOffset := len(blob) - historyBoundBytes | |||
| 19 | + | ||||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | mutate func([]byte) | |||
| 23 | + | }{ | |||
| 24 | + | { | |||
| 25 | + | name: "end exceeds row width", | |||
| 26 | + | mutate: func(data []byte) { | |||
| 27 | + | binary.BigEndian.PutUint32(data[boundOffset:boundOffset+4], 5) | |||
| 28 | + | }, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "soft flag is not canonical", | |||
| 32 | + | mutate: func(data []byte) { | |||
| 33 | + | data[boundOffset+4] = 2 | |||
| 34 | + | }, | |||
| 35 | + | }, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | for _, tt := range tests { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | data := append([]byte(nil), blob...) | |||
| 41 | + | tt.mutate(data) | |||
| 42 | + | ||||
| 43 | + | _, preflightErr := PreflightHistoryBlob(data) | |||
| 44 | + | require.Error(t, preflightErr) | |||
| 45 | + | _, unmarshalErr := UnmarshalHistory(data) | |||
| 46 | + | require.Error(t, unmarshalErr) | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
M pkg/vt/history_codec_test.go +1 -1
| @@ -82,7 +82,7 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||
| 82 | 82 | ||
| 83 | 83 | // This is a genuine v1-layout payload: version 1 and no trailing bound | |
| 84 | 84 | // bytes. It is not a v2 payload with only its version byte changed. | |
| 85 | - | v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...) | |
| 85 | + | v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...) | |
| 86 | 86 | v1[4] = 1 | |
| 87 | 87 | if _, err := UnmarshalHistory(v1); err == nil { | |
| 88 | 88 | t.Fatal("accepted a genuine v1 history payload") | |
| @@ -82,7 +82,7 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||||
| 82 | 82 | ||||
| 83 | // This is a genuine v1-layout payload: version 1 and no trailing bound | 83 | // This is a genuine v1-layout payload: version 1 and no trailing bound | ||
| 84 | // bytes. It is not a v2 payload with only its version byte changed. | 84 | // bytes. It is not a v2 payload with only its version byte changed. | ||
| 85 | - | v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...) | 85 | + | v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...) |
| 86 | v1[4] = 1 | 86 | v1[4] = 1 | ||
| 87 | if _, err := UnmarshalHistory(v1); err == nil { | 87 | if _, err := UnmarshalHistory(v1); err == nil { | ||
| 88 | t.Fatal("accepted a genuine v1 history payload") | 88 | t.Fatal("accepted a genuine v1 history payload") | ||
R pkg/vt/primary_visible_snapshot_test.go → pkg/vt/recovery_transcript_ownership_test.go +5 -5 renamed, 58% similar
| @@ -6,12 +6,12 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | |
| 9 | + | func TestRecoveryTranscriptSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | |
| 10 | 10 | s := NewScreen(8, 2) | |
| 11 | 11 | s.Write([]byte("primary")) | |
| 12 | 12 | s.Write([]byte("\x1b[?1049h")) | |
| 13 | 13 | ||
| 14 | - | captured := s.PrimaryVisibleSnapshot() | |
| 14 | + | captured := s.RecoveryTranscriptSnapshot() | |
| 15 | 15 | mutated := make(chan struct{}) | |
| 16 | 16 | go func() { | |
| 17 | 17 | s.Write([]byte("alternate")) | |
| @@ -23,7 +23,7 @@ func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) | |||
| 23 | 23 | blob, err := captured.Marshal() | |
| 24 | 24 | <-mutated | |
| 25 | 25 | require.NoError(t, err) | |
| 26 | - | restored := NewScreen(1, 1) | |
| 27 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |
| 28 | - | require.Equal(t, "primary ", rowString(restored.Frame.Row(0))) | |
| 26 | + | view, err := UnmarshalHistory(blob) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.Equal(t, []string{"primary "}, historyViewTexts(view)) | |
| 29 | 29 | } | |
| @@ -6,12 +6,12 @@ import ( | |||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | 9 | + | func TestRecoveryTranscriptSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { |
| 10 | s := NewScreen(8, 2) | 10 | s := NewScreen(8, 2) | ||
| 11 | s.Write([]byte("primary")) | 11 | s.Write([]byte("primary")) | ||
| 12 | s.Write([]byte("\x1b[?1049h")) | 12 | s.Write([]byte("\x1b[?1049h")) | ||
| 13 | 13 | ||||
| 14 | - | captured := s.PrimaryVisibleSnapshot() | 14 | + | captured := s.RecoveryTranscriptSnapshot() |
| 15 | mutated := make(chan struct{}) | 15 | mutated := make(chan struct{}) | ||
| 16 | go func() { | 16 | go func() { | ||
| 17 | s.Write([]byte("alternate")) | 17 | s.Write([]byte("alternate")) | ||
| @@ -23,7 +23,7 @@ func TestPrimaryVisibleSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) | |||||
| 23 | blob, err := captured.Marshal() | 23 | blob, err := captured.Marshal() | ||
| 24 | <-mutated | 24 | <-mutated | ||
| 25 | require.NoError(t, err) | 25 | require.NoError(t, err) | ||
| 26 | - | restored := NewScreen(1, 1) | 26 | + | view, err := UnmarshalHistory(blob) |
| 27 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | 27 | + | require.NoError(t, err) |
| 28 | - | require.Equal(t, "primary ", rowString(restored.Frame.Row(0))) | 28 | + | require.Equal(t, []string{"primary "}, historyViewTexts(view)) |
| 29 | } | 29 | } | ||
M pkg/vt/recovery_transcript_test.go +51 -0
| @@ -25,6 +25,17 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||
| 25 | 25 | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) { | |
| 29 | + | s := NewScreen(5, 3) | |
| 30 | + | s.Write([]byte("abcdefgh")) | |
| 31 | + | ||
| 32 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| 33 | + | ||
| 34 | + | require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view)) | |
| 35 | + | require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0)) | |
| 36 | + | require.Equal(t, LineBound{End: 3}, view.Bound(1)) | |
| 37 | + | } | |
| 38 | + | ||
| 28 | 39 | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | |
| 29 | 40 | const ( | |
| 30 | 41 | shortHeight = 8 | |
| @@ -288,6 +299,46 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing | |||
| 288 | 299 | require.Nil(t, screen) | |
| 289 | 300 | } | |
| 290 | 301 | ||
| 302 | + | func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |
| 303 | + | for _, tt := range []struct { | |
| 304 | + | name string | |
| 305 | + | alternate bool | |
| 306 | + | wantRows int | |
| 307 | + | }{ | |
| 308 | + | {name: "primary", wantRows: 40}, | |
| 309 | + | {name: "primary-and-active-alternate", alternate: true, wantRows: 80}, | |
| 310 | + | } { | |
| 311 | + | b.Run(tt.name, func(b *testing.B) { | |
| 312 | + | screen := NewScreen(120, 40) | |
| 313 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |
| 314 | + | if tt.alternate { | |
| 315 | + | screen.Write([]byte("\x1b[?1049h")) | |
| 316 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | b.ReportAllocs() | |
| 320 | + | b.ReportMetric(float64(tt.wantRows), "rows/snapshot") | |
| 321 | + | b.ResetTimer() | |
| 322 | + | for b.Loop() { | |
| 323 | + | recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot() | |
| 324 | + | } | |
| 325 | + | b.StopTimer() | |
| 326 | + | if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows { | |
| 327 | + | b.Fatalf("captured rows = %d, want %d", got, tt.wantRows) | |
| 328 | + | } | |
| 329 | + | }) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | |
| 334 | + | for y := range screen.Frame.Height { | |
| 335 | + | for x := range screen.Frame.Width { | |
| 336 | + | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |
| 337 | + | } | |
| 338 | + | screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width} | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | ||
| 291 | 342 | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |
| 292 | 343 | const rows = maxHistoryRows | |
| 293 | 344 | source := NewScreen(1, rows) | |
| @@ -25,6 +25,17 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||||
| 25 | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | 25 | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | + | func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) { | |||
| 29 | + | s := NewScreen(5, 3) | |||
| 30 | + | s.Write([]byte("abcdefgh")) | |||
| 31 | + | ||||
| 32 | + | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 33 | + | ||||
| 34 | + | require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view)) | |||
| 35 | + | require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0)) | |||
| 36 | + | require.Equal(t, LineBound{End: 3}, view.Bound(1)) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 28 | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | 39 | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | ||
| 29 | const ( | 40 | const ( | ||
| 30 | shortHeight = 8 | 41 | shortHeight = 8 | ||
| @@ -288,6 +299,46 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing | |||||
| 288 | require.Nil(t, screen) | 299 | require.Nil(t, screen) | ||
| 289 | } | 300 | } | ||
| 290 | 301 | ||||
| 302 | + | func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |||
| 303 | + | for _, tt := range []struct { | |||
| 304 | + | name string | |||
| 305 | + | alternate bool | |||
| 306 | + | wantRows int | |||
| 307 | + | }{ | |||
| 308 | + | {name: "primary", wantRows: 40}, | |||
| 309 | + | {name: "primary-and-active-alternate", alternate: true, wantRows: 80}, | |||
| 310 | + | } { | |||
| 311 | + | b.Run(tt.name, func(b *testing.B) { | |||
| 312 | + | screen := NewScreen(120, 40) | |||
| 313 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |||
| 314 | + | if tt.alternate { | |||
| 315 | + | screen.Write([]byte("\x1b[?1049h")) | |||
| 316 | + | fillRecoveryTranscriptBenchmarkFrame(screen) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | b.ReportAllocs() | |||
| 320 | + | b.ReportMetric(float64(tt.wantRows), "rows/snapshot") | |||
| 321 | + | b.ResetTimer() | |||
| 322 | + | for b.Loop() { | |||
| 323 | + | recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot() | |||
| 324 | + | } | |||
| 325 | + | b.StopTimer() | |||
| 326 | + | if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows { | |||
| 327 | + | b.Fatalf("captured rows = %d, want %d", got, tt.wantRows) | |||
| 328 | + | } | |||
| 329 | + | }) | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | |||
| 334 | + | for y := range screen.Frame.Height { | |||
| 335 | + | for x := range screen.Frame.Width { | |||
| 336 | + | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |||
| 337 | + | } | |||
| 338 | + | screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width} | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 291 | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | 342 | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | ||
| 292 | const rows = maxHistoryRows | 343 | const rows = maxHistoryRows | ||
| 293 | source := NewScreen(1, rows) | 344 | source := NewScreen(1, rows) | ||
M pkg/vt/resize_reflow_regression_test.go +1 -29
| @@ -1,11 +1,6 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | - | "testing" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | - | ) | |
| 3 | + | import "testing" | |
| 9 | 4 | ||
| 10 | 5 | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | |
| 11 | 6 | s := NewScreen(5, 3) | |
| @@ -57,26 +52,3 @@ func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |||
| 57 | 52 | assertCell(t, s, x, 0, r) | |
| 58 | 53 | } | |
| 59 | 54 | } | |
| 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 | - | } | |
| @@ -1,11 +1,6 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "testing" |
| 4 | - | "encoding/binary" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | 4 | ||||
| 10 | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | 5 | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | ||
| 11 | s := NewScreen(5, 3) | 6 | s := NewScreen(5, 3) | ||
| @@ -57,26 +52,3 @@ func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |||||
| 57 | assertCell(t, s, x, 0, r) | 52 | assertCell(t, s, x, 0, r) | ||
| 58 | } | 53 | } | ||
| 59 | } | 54 | } | ||
| 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/snapshot.go +0 -222
| @@ -1,16 +1,8 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | "math" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | const ( | |
| 12 | - | visibleMagic = "VTV2" | |
| 13 | - | visibleBoundaryBytes = 5 | |
| 14 | 6 | ) | |
| 15 | 7 | ||
| 16 | 8 | // DecodeStats describes resources declared by one canonical VT blob. | |
| @@ -142,217 +134,3 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 142 | 134 | screen.history = history | |
| 143 | 135 | return screen, nil | |
| 144 | 136 | } | |
| 145 | - | ||
| 146 | - | // MarshalVisible encodes the exact visible primary frame, including every | |
| 147 | - | // blank, row width, and terminal cell attribute. | |
| 148 | - | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |
| 149 | - | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { | |
| 150 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 151 | - | } | |
| 152 | - | boundaries := make([]LineBound, frame.Height) | |
| 153 | - | for y := range boundaries { | |
| 154 | - | for x := frame.Width - 1; x >= 0; x-- { | |
| 155 | - | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |
| 156 | - | boundaries[y].End = x + 1 | |
| 157 | - | break | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | return marshalVisible(frame, boundaries) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible | |
| 165 | - | // state. Capture it while the screen owner is synchronized, then Marshal it | |
| 166 | - | // after releasing that lock. | |
| 167 | - | type PrimaryVisibleSnapshot struct { | |
| 168 | - | frame renderer.Frame | |
| 169 | - | boundaries []LineBound | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | |
| 173 | - | // boundaries. When the alternate screen is active, the saved primary buffer is | |
| 174 | - | // copied rather than the currently displayed alternate buffer. | |
| 175 | - | func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot { | |
| 176 | - | b := s.buffer | |
| 177 | - | if s.alternate != nil { | |
| 178 | - | b = s.alternate.buffer | |
| 179 | - | } | |
| 180 | - | if b == nil { | |
| 181 | - | return PrimaryVisibleSnapshot{frame: s.PrimaryVisibleFrame()} | |
| 182 | - | } | |
| 183 | - | return PrimaryVisibleSnapshot{ | |
| 184 | - | frame: b.frame.Clone(), | |
| 185 | - | boundaries: append([]LineBound(nil), b.boundaries...), | |
| 186 | - | } | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | // Marshal encodes an owned primary visible snapshot without accessing live VT | |
| 190 | - | // state. | |
| 191 | - | func (v PrimaryVisibleSnapshot) Marshal() ([]byte, error) { | |
| 192 | - | if len(v.boundaries) == 0 && v.frame.Height > 0 { | |
| 193 | - | return MarshalVisible(v.frame) | |
| 194 | - | } | |
| 195 | - | return marshalVisible(v.frame, v.boundaries) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |
| 199 | - | // alongside its cells so a restored viewport can subsequently reflow. | |
| 200 | - | // | |
| 201 | - | // Deprecated: callers holding a pane lock should use PrimaryVisibleSnapshot | |
| 202 | - | // and marshal the result after unlocking. | |
| 203 | - | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |
| 204 | - | return s.PrimaryVisibleSnapshot().Marshal() | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | func marshalVisible(frame renderer.Frame, boundaries []LineBound) ([]byte, error) { | |
| 208 | - | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |
| 209 | - | if !ok || len(boundaries) != frame.Height { | |
| 210 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 211 | - | } | |
| 212 | - | out := make([]byte, 0, int(13+bytes+boundaryBytes)) | |
| 213 | - | out = append(out, visibleMagic...) | |
| 214 | - | out = append(out, historyVersion) | |
| 215 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| 216 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |
| 217 | - | for y := range frame.Height { | |
| 218 | - | for _, cell := range frame.Row(y) { | |
| 219 | - | if !validHistoryCell(cell) { | |
| 220 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 221 | - | } | |
| 222 | - | out = appendHistoryCell(out, cell) | |
| 223 | - | } | |
| 224 | - | } | |
| 225 | - | for _, boundary := range boundaries { | |
| 226 | - | out = binary.BigEndian.AppendUint32(out, uint32(boundary.End)) | |
| 227 | - | if boundary.Soft { | |
| 228 | - | out = append(out, 1) | |
| 229 | - | } else { | |
| 230 | - | out = append(out, 0) | |
| 231 | - | } | |
| 232 | - | } | |
| 233 | - | return out, nil | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | // PreflightVisibleBlob validates dimensions and cell declarations without | |
| 237 | - | // allocating a frame. | |
| 238 | - | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |
| 239 | - | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |
| 240 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 241 | - | } | |
| 242 | - | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |
| 243 | - | cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height) | |
| 244 | - | if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes { | |
| 245 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 246 | - | } | |
| 247 | - | p := historyParser{data: data[13:]} | |
| 248 | - | for range cells { | |
| 249 | - | cell, ok := p.cell() | |
| 250 | - | if !ok || !validHistoryCell(cell) { | |
| 251 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 252 | - | } | |
| 253 | - | } | |
| 254 | - | for range height { | |
| 255 | - | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |
| 256 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 257 | - | } | |
| 258 | - | p.data = p.data[5:] | |
| 259 | - | } | |
| 260 | - | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |
| 264 | - | if width < 0 || height < 0 { | |
| 265 | - | return 0, 0, 0, false | |
| 266 | - | } | |
| 267 | - | return visibleEncodingBudget64(uint64(width), uint64(height)) | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |
| 271 | - | if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes { | |
| 272 | - | return 0, 0, 0, false | |
| 273 | - | } | |
| 274 | - | cells = width * height | |
| 275 | - | cellBytes, ok = historyCellByteCount(cells) | |
| 276 | - | if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes { | |
| 277 | - | return 0, 0, 0, false | |
| 278 | - | } | |
| 279 | - | boundaryBytes = height * visibleBoundaryBytes | |
| 280 | - | if boundaryBytes > maxVisibleBoundaryBytes { | |
| 281 | - | return 0, 0, 0, false | |
| 282 | - | } | |
| 283 | - | return cells, cellBytes, boundaryBytes, true | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | // UnmarshalVisible decodes a preflighted exact visible frame. | |
| 287 | - | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |
| 288 | - | frame, _, err := unmarshalVisible(data) | |
| 289 | - | return frame, err | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | func unmarshalVisible(data []byte) (renderer.Frame, []LineBound, error) { | |
| 293 | - | if _, err := PreflightVisibleBlob(data); err != nil { | |
| 294 | - | return renderer.Frame{}, nil, err | |
| 295 | - | } | |
| 296 | - | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |
| 297 | - | frame := renderer.NewFrame(width, height) | |
| 298 | - | cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height)) | |
| 299 | - | p := historyParser{data: data[13 : 13+cellBytes]} | |
| 300 | - | for y := range height { | |
| 301 | - | for x := range width { | |
| 302 | - | cell, _ := p.cell() | |
| 303 | - | frame.Set(x, y, cell) | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | boundaries := make([]LineBound, height) | |
| 307 | - | offset := 13 + int(cellBytes) | |
| 308 | - | for y := range boundaries { | |
| 309 | - | boundaries[y] = LineBound{End: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), Soft: data[offset+4] == 1} | |
| 310 | - | offset += 5 | |
| 311 | - | } | |
| 312 | - | return frame, boundaries, nil | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |
| 316 | - | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |
| 317 | - | if s.alternate != nil { | |
| 318 | - | return s.alternate.frame.Clone() | |
| 319 | - | } | |
| 320 | - | return s.Frame.Clone() | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |
| 324 | - | // When the alternate screen is active, it snapshots the saved primary screen | |
| 325 | - | // rather than the currently displayed alternate frame. | |
| 326 | - | func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |
| 327 | - | frame := s.Frame | |
| 328 | - | if s.alternate != nil { | |
| 329 | - | frame = s.alternate.frame | |
| 330 | - | } | |
| 331 | - | rows := make([][]renderer.Cell, frame.Height) | |
| 332 | - | for y := range frame.Height { | |
| 333 | - | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |
| 334 | - | } | |
| 335 | - | return rows | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |
| 339 | - | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |
| 340 | - | frame, boundaries, err := unmarshalVisible(blob) | |
| 341 | - | if err != nil { | |
| 342 | - | return err | |
| 343 | - | } | |
| 344 | - | b := bufferFromFrame(frame) | |
| 345 | - | b.boundaries = boundaries | |
| 346 | - | // A persisted primary frame is authoritative: restoring it directly keeps | |
| 347 | - | // exact row widths and height even when a collapsed layout chose a smaller | |
| 348 | - | // initial PTY size. A subsequent resize reconciles it with live geometry. | |
| 349 | - | if s.alternate != nil { | |
| 350 | - | s.alternate.frame = frame | |
| 351 | - | s.alternate.buffer = b | |
| 352 | - | } else { | |
| 353 | - | s.buffer = b | |
| 354 | - | s.Frame = b.frame | |
| 355 | - | } | |
| 356 | - | s.fullRedraw() | |
| 357 | - | return nil | |
| 358 | - | } | |
| @@ -1,16 +1,8 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "encoding/binary" | |||
| 5 | "fmt" | 4 | "fmt" | ||
| 6 | "math" | 5 | "math" | ||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | const ( | |||
| 12 | - | visibleMagic = "VTV2" | |||
| 13 | - | visibleBoundaryBytes = 5 | |||
| 14 | ) | 6 | ) | ||
| 15 | 7 | ||||
| 16 | // DecodeStats describes resources declared by one canonical VT blob. | 8 | // DecodeStats describes resources declared by one canonical VT blob. | ||
| @@ -142,217 +134,3 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 142 | screen.history = history | 134 | screen.history = history | ||
| 143 | return screen, nil | 135 | return screen, nil | ||
| 144 | } | 136 | } | ||
| 145 | - | ||||
| 146 | - | // MarshalVisible encodes the exact visible primary frame, including every | |||
| 147 | - | // blank, row width, and terminal cell attribute. | |||
| 148 | - | func MarshalVisible(frame renderer.Frame) ([]byte, error) { | |||
| 149 | - | if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok { | |||
| 150 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 151 | - | } | |||
| 152 | - | boundaries := make([]LineBound, frame.Height) | |||
| 153 | - | for y := range boundaries { | |||
| 154 | - | for x := frame.Width - 1; x >= 0; x-- { | |||
| 155 | - | if !frame.At(x, y).Equal(renderer.BlankCell()) { | |||
| 156 | - | boundaries[y].End = x + 1 | |||
| 157 | - | break | |||
| 158 | - | } | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | return marshalVisible(frame, boundaries) | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // PrimaryVisibleSnapshot is an owned copy of the primary buffer's visible | |||
| 165 | - | // state. Capture it while the screen owner is synchronized, then Marshal it | |||
| 166 | - | // after releasing that lock. | |||
| 167 | - | type PrimaryVisibleSnapshot struct { | |||
| 168 | - | frame renderer.Frame | |||
| 169 | - | boundaries []LineBound | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | // PrimaryVisibleSnapshot copies the saved primary frame and its logical-line | |||
| 173 | - | // boundaries. When the alternate screen is active, the saved primary buffer is | |||
| 174 | - | // copied rather than the currently displayed alternate buffer. | |||
| 175 | - | func (s *Screen) PrimaryVisibleSnapshot() PrimaryVisibleSnapshot { | |||
| 176 | - | b := s.buffer | |||
| 177 | - | if s.alternate != nil { | |||
| 178 | - | b = s.alternate.buffer | |||
| 179 | - | } | |||
| 180 | - | if b == nil { | |||
| 181 | - | return PrimaryVisibleSnapshot{frame: s.PrimaryVisibleFrame()} | |||
| 182 | - | } | |||
| 183 | - | return PrimaryVisibleSnapshot{ | |||
| 184 | - | frame: b.frame.Clone(), | |||
| 185 | - | boundaries: append([]LineBound(nil), b.boundaries...), | |||
| 186 | - | } | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | // Marshal encodes an owned primary visible snapshot without accessing live VT | |||
| 190 | - | // state. | |||
| 191 | - | func (v PrimaryVisibleSnapshot) Marshal() ([]byte, error) { | |||
| 192 | - | if len(v.boundaries) == 0 && v.frame.Height > 0 { | |||
| 193 | - | return MarshalVisible(v.frame) | |||
| 194 | - | } | |||
| 195 | - | return marshalVisible(v.frame, v.boundaries) | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // MarshalPrimaryVisible preserves the primary buffer's logical-line boundaries | |||
| 199 | - | // alongside its cells so a restored viewport can subsequently reflow. | |||
| 200 | - | // | |||
| 201 | - | // Deprecated: callers holding a pane lock should use PrimaryVisibleSnapshot | |||
| 202 | - | // and marshal the result after unlocking. | |||
| 203 | - | func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |||
| 204 | - | return s.PrimaryVisibleSnapshot().Marshal() | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | func marshalVisible(frame renderer.Frame, boundaries []LineBound) ([]byte, error) { | |||
| 208 | - | _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height) | |||
| 209 | - | if !ok || len(boundaries) != frame.Height { | |||
| 210 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 211 | - | } | |||
| 212 | - | out := make([]byte, 0, int(13+bytes+boundaryBytes)) | |||
| 213 | - | out = append(out, visibleMagic...) | |||
| 214 | - | out = append(out, historyVersion) | |||
| 215 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |||
| 216 | - | out = binary.BigEndian.AppendUint32(out, uint32(frame.Height)) | |||
| 217 | - | for y := range frame.Height { | |||
| 218 | - | for _, cell := range frame.Row(y) { | |||
| 219 | - | if !validHistoryCell(cell) { | |||
| 220 | - | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |||
| 221 | - | } | |||
| 222 | - | out = appendHistoryCell(out, cell) | |||
| 223 | - | } | |||
| 224 | - | } | |||
| 225 | - | for _, boundary := range boundaries { | |||
| 226 | - | out = binary.BigEndian.AppendUint32(out, uint32(boundary.End)) | |||
| 227 | - | if boundary.Soft { | |||
| 228 | - | out = append(out, 1) | |||
| 229 | - | } else { | |||
| 230 | - | out = append(out, 0) | |||
| 231 | - | } | |||
| 232 | - | } | |||
| 233 | - | return out, nil | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // PreflightVisibleBlob validates dimensions and cell declarations without | |||
| 237 | - | // allocating a frame. | |||
| 238 | - | func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 239 | - | if len(data) < 13 || string(data[:4]) != visibleMagic || data[4] != historyVersion { | |||
| 240 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 241 | - | } | |||
| 242 | - | width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13])) | |||
| 243 | - | cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height) | |||
| 244 | - | if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes { | |||
| 245 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 246 | - | } | |||
| 247 | - | p := historyParser{data: data[13:]} | |||
| 248 | - | for range cells { | |||
| 249 | - | cell, ok := p.cell() | |||
| 250 | - | if !ok || !validHistoryCell(cell) { | |||
| 251 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 252 | - | } | |||
| 253 | - | } | |||
| 254 | - | for range height { | |||
| 255 | - | if uint64(binary.BigEndian.Uint32(p.data[:4])) > width || p.data[4] > 1 { | |||
| 256 | - | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |||
| 257 | - | } | |||
| 258 | - | p.data = p.data[5:] | |||
| 259 | - | } | |||
| 260 | - | return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil | |||
| 261 | - | } | |||
| 262 | - | ||||
| 263 | - | func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |||
| 264 | - | if width < 0 || height < 0 { | |||
| 265 | - | return 0, 0, 0, false | |||
| 266 | - | } | |||
| 267 | - | return visibleEncodingBudget64(uint64(width), uint64(height)) | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) { | |||
| 271 | - | if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes { | |||
| 272 | - | return 0, 0, 0, false | |||
| 273 | - | } | |||
| 274 | - | cells = width * height | |||
| 275 | - | cellBytes, ok = historyCellByteCount(cells) | |||
| 276 | - | if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes { | |||
| 277 | - | return 0, 0, 0, false | |||
| 278 | - | } | |||
| 279 | - | boundaryBytes = height * visibleBoundaryBytes | |||
| 280 | - | if boundaryBytes > maxVisibleBoundaryBytes { | |||
| 281 | - | return 0, 0, 0, false | |||
| 282 | - | } | |||
| 283 | - | return cells, cellBytes, boundaryBytes, true | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | // UnmarshalVisible decodes a preflighted exact visible frame. | |||
| 287 | - | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |||
| 288 | - | frame, _, err := unmarshalVisible(data) | |||
| 289 | - | return frame, err | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | - | func unmarshalVisible(data []byte) (renderer.Frame, []LineBound, error) { | |||
| 293 | - | if _, err := PreflightVisibleBlob(data); err != nil { | |||
| 294 | - | return renderer.Frame{}, nil, err | |||
| 295 | - | } | |||
| 296 | - | width, height := int(binary.BigEndian.Uint32(data[5:9])), int(binary.BigEndian.Uint32(data[9:13])) | |||
| 297 | - | frame := renderer.NewFrame(width, height) | |||
| 298 | - | cellBytes, _ := historyCellByteCount(uint64(width) * uint64(height)) | |||
| 299 | - | p := historyParser{data: data[13 : 13+cellBytes]} | |||
| 300 | - | for y := range height { | |||
| 301 | - | for x := range width { | |||
| 302 | - | cell, _ := p.cell() | |||
| 303 | - | frame.Set(x, y, cell) | |||
| 304 | - | } | |||
| 305 | - | } | |||
| 306 | - | boundaries := make([]LineBound, height) | |||
| 307 | - | offset := 13 + int(cellBytes) | |||
| 308 | - | for y := range boundaries { | |||
| 309 | - | boundaries[y] = LineBound{End: clamp(int(binary.BigEndian.Uint32(data[offset:offset+4])), 0, width), Soft: data[offset+4] == 1} | |||
| 310 | - | offset += 5 | |||
| 311 | - | } | |||
| 312 | - | return frame, boundaries, nil | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | // PrimaryVisibleFrame returns a deep copy of the exact visible primary frame. | |||
| 316 | - | func (s *Screen) PrimaryVisibleFrame() renderer.Frame { | |||
| 317 | - | if s.alternate != nil { | |||
| 318 | - | return s.alternate.frame.Clone() | |||
| 319 | - | } | |||
| 320 | - | return s.Frame.Clone() | |||
| 321 | - | } | |||
| 322 | - | ||||
| 323 | - | // PrimaryVisibleRows returns a deep copy of the visible primary-screen rows. | |||
| 324 | - | // When the alternate screen is active, it snapshots the saved primary screen | |||
| 325 | - | // rather than the currently displayed alternate frame. | |||
| 326 | - | func (s *Screen) PrimaryVisibleRows() [][]renderer.Cell { | |||
| 327 | - | frame := s.Frame | |||
| 328 | - | if s.alternate != nil { | |||
| 329 | - | frame = s.alternate.frame | |||
| 330 | - | } | |||
| 331 | - | rows := make([][]renderer.Cell, frame.Height) | |||
| 332 | - | for y := range frame.Height { | |||
| 333 | - | rows[y] = append([]renderer.Cell(nil), frame.Row(y)...) | |||
| 334 | - | } | |||
| 335 | - | return rows | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | // RestorePrimaryVisible replaces the primary frame from an exact visible blob. | |||
| 339 | - | func (s *Screen) RestorePrimaryVisible(blob []byte) error { | |||
| 340 | - | frame, boundaries, err := unmarshalVisible(blob) | |||
| 341 | - | if err != nil { | |||
| 342 | - | return err | |||
| 343 | - | } | |||
| 344 | - | b := bufferFromFrame(frame) | |||
| 345 | - | b.boundaries = boundaries | |||
| 346 | - | // A persisted primary frame is authoritative: restoring it directly keeps | |||
| 347 | - | // exact row widths and height even when a collapsed layout chose a smaller | |||
| 348 | - | // initial PTY size. A subsequent resize reconciles it with live geometry. | |||
| 349 | - | if s.alternate != nil { | |||
| 350 | - | s.alternate.frame = frame | |||
| 351 | - | s.alternate.buffer = b | |||
| 352 | - | } else { | |||
| 353 | - | s.buffer = b | |||
| 354 | - | s.Frame = b.frame | |||
| 355 | - | } | |||
| 356 | - | s.fullRedraw() | |||
| 357 | - | return nil | |||
| 358 | - | } | |||
D pkg/vt/snapshot_boundary_test.go +0 -43
| @@ -1,43 +0,0 @@ | |||
| 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 | - | } | |
| @@ -1,43 +0,0 @@ | |||||
| 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 +0 -46
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strings" | |
| 5 | 4 | "testing" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -79,48 +78,3 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing | |||
| 79 | 78 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | |
| 80 | 79 | require.Equal(t, 2, restored.Cells()) | |
| 81 | 80 | } | |
| 82 | - | ||
| 83 | - | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | |
| 84 | - | s := NewScreen(5, 3) | |
| 85 | - | s.Write([]byte("abcdefgh")) | |
| 86 | - | blob, err := s.MarshalPrimaryVisible() | |
| 87 | - | require.NoError(t, err) | |
| 88 | - | ||
| 89 | - | restored := NewScreen(1, 1) | |
| 90 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |
| 91 | - | restored.Resize(3, 3) | |
| 92 | - | require.Equal(t, "abc", rowString(restored.Frame.Row(0))) | |
| 93 | - | require.Equal(t, "def", rowString(restored.Frame.Row(1))) | |
| 94 | - | require.Equal(t, "gh ", rowString(restored.Frame.Row(2))) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |
| 98 | - | s := NewScreen(6, 2) | |
| 99 | - | s.Write([]byte("hello")) | |
| 100 | - | ||
| 101 | - | rows := s.PrimaryVisibleRows() | |
| 102 | - | require.Len(t, rows, 2) | |
| 103 | - | require.Equal(t, "hello ", rowString(rows[0])) | |
| 104 | - | ||
| 105 | - | rows[0][0].Rune = 'X' | |
| 106 | - | rows[0] = nil | |
| 107 | - | ||
| 108 | - | require.Equal(t, "hello ", rowString(s.Frame.Row(0))) | |
| 109 | - | require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0])) | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) { | |
| 113 | - | s := NewScreen(8, 2) | |
| 114 | - | s.Write([]byte("primary")) | |
| 115 | - | s.Write([]byte("\x1b[?1049h")) | |
| 116 | - | s.Write([]byte("alt")) | |
| 117 | - | ||
| 118 | - | rows := s.PrimaryVisibleRows() | |
| 119 | - | require.Len(t, rows, 2) | |
| 120 | - | require.Equal(t, "primary ", rowString(rows[0])) | |
| 121 | - | require.False(t, strings.Contains(rowString(rows[0]), "alt")) | |
| 122 | - | ||
| 123 | - | rows[0][0].Rune = 'X' | |
| 124 | - | s.Write([]byte("\x1b[?1049l")) | |
| 125 | - | require.Equal(t, "primary ", rowString(s.Frame.Row(0))) | |
| 126 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "strings" | |||
| 5 | "testing" | 4 | "testing" | ||
| 6 | 5 | ||||
| 7 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -79,48 +78,3 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing | |||||
| 79 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | 78 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | ||
| 80 | require.Equal(t, 2, restored.Cells()) | 79 | require.Equal(t, 2, restored.Cells()) | ||
| 81 | } | 80 | } | ||
| 82 | - | ||||
| 83 | - | func TestPrimaryVisibleSnapshotRetainsReflowBoundaries(t *testing.T) { | |||
| 84 | - | s := NewScreen(5, 3) | |||
| 85 | - | s.Write([]byte("abcdefgh")) | |||
| 86 | - | blob, err := s.MarshalPrimaryVisible() | |||
| 87 | - | require.NoError(t, err) | |||
| 88 | - | ||||
| 89 | - | restored := NewScreen(1, 1) | |||
| 90 | - | require.NoError(t, restored.RestorePrimaryVisible(blob)) | |||
| 91 | - | restored.Resize(3, 3) | |||
| 92 | - | require.Equal(t, "abc", rowString(restored.Frame.Row(0))) | |||
| 93 | - | require.Equal(t, "def", rowString(restored.Frame.Row(1))) | |||
| 94 | - | require.Equal(t, "gh ", rowString(restored.Frame.Row(2))) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | func TestPrimaryVisibleRowsCopiesActivePrimaryScreen(t *testing.T) { | |||
| 98 | - | s := NewScreen(6, 2) | |||
| 99 | - | s.Write([]byte("hello")) | |||
| 100 | - | ||||
| 101 | - | rows := s.PrimaryVisibleRows() | |||
| 102 | - | require.Len(t, rows, 2) | |||
| 103 | - | require.Equal(t, "hello ", rowString(rows[0])) | |||
| 104 | - | ||||
| 105 | - | rows[0][0].Rune = 'X' | |||
| 106 | - | rows[0] = nil | |||
| 107 | - | ||||
| 108 | - | require.Equal(t, "hello ", rowString(s.Frame.Row(0))) | |||
| 109 | - | require.Equal(t, "hello ", rowString(s.PrimaryVisibleRows()[0])) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func TestPrimaryVisibleRowsUsesSavedPrimaryScreenWhenAlternateActive(t *testing.T) { | |||
| 113 | - | s := NewScreen(8, 2) | |||
| 114 | - | s.Write([]byte("primary")) | |||
| 115 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 116 | - | s.Write([]byte("alt")) | |||
| 117 | - | ||||
| 118 | - | rows := s.PrimaryVisibleRows() | |||
| 119 | - | require.Len(t, rows, 2) | |||
| 120 | - | require.Equal(t, "primary ", rowString(rows[0])) | |||
| 121 | - | require.False(t, strings.Contains(rowString(rows[0]), "alt")) | |||
| 122 | - | ||||
| 123 | - | rows[0][0].Rune = 'X' | |||
| 124 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 125 | - | require.Equal(t, "primary ", rowString(s.Frame.Row(0))) | |||
| 126 | - | } | |||