refactor(vt): own immutable terminal history chunks
- commit
- e8f2961ff9a2a77e1bd05664abfd95e091feba81
- author
- brice · · 2026-07-12 15:30 UTC
- parent
- bf359d3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +362 -7
A pkg/vt/history.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // HistoryConfig controls the bounded terminal history retained by a Screen. | |
| 6 | + | type HistoryConfig struct { | |
| 7 | + | MaxRows int | |
| 8 | + | ChunkRows int | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | // History stores terminal rows in immutable chunks. It is intended to be | |
| 12 | + | // mutated by the owner of a Screen; views are safe to retain after later | |
| 13 | + | // appends. | |
| 14 | + | type History struct { | |
| 15 | + | maxRows int | |
| 16 | + | chunkRows int | |
| 17 | + | chunks []*HistoryChunk | |
| 18 | + | tail [][]renderer.Cell | |
| 19 | + | rows int | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| 23 | + | // and can be used by consumers to avoid copying unchanged sealed chunks. | |
| 24 | + | type HistoryChunk struct { | |
| 25 | + | rows [][]renderer.Cell | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| 29 | + | // storage behind a sealed chunk remains owned by VT. | |
| 30 | + | type HistoryView struct { | |
| 31 | + | chunks []*HistoryChunk | |
| 32 | + | rows int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func NewHistory(config HistoryConfig) *History { | |
| 36 | + | if config.MaxRows <= 0 { | |
| 37 | + | return &History{} | |
| 38 | + | } | |
| 39 | + | chunkRows := config.ChunkRows | |
| 40 | + | if chunkRows <= 0 { | |
| 41 | + | chunkRows = 256 | |
| 42 | + | } | |
| 43 | + | chunkRows = min(chunkRows, 256) | |
| 44 | + | chunkRows = min(chunkRows, config.MaxRows) | |
| 45 | + | return &History{maxRows: config.MaxRows, chunkRows: chunkRows} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Append records a copy of row. Once a chunk is full it is sealed forever. | |
| 49 | + | func (h *History) Append(row []renderer.Cell) { | |
| 50 | + | if h == nil || h.maxRows == 0 { | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 54 | + | h.rows++ | |
| 55 | + | if len(h.tail) == h.chunkRows { | |
| 56 | + | h.sealTail() | |
| 57 | + | } | |
| 58 | + | h.evict() | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (h *History) sealTail() { | |
| 62 | + | if len(h.tail) == 0 { | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail}) | |
| 66 | + | h.tail = nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (h *History) evict() { | |
| 70 | + | for h.rows > h.maxRows && len(h.chunks) > 0 { | |
| 71 | + | evicted := h.chunks[0] | |
| 72 | + | h.rows -= len(evicted.rows) | |
| 73 | + | copy(h.chunks, h.chunks[1:]) | |
| 74 | + | h.chunks[len(h.chunks)-1] = nil | |
| 75 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // View captures the current history. Sealed chunks are shared by identity; a | |
| 80 | + | // partially-filled tail is copied into a new immutable chunk for this view. | |
| 81 | + | func (h *History) View() HistoryView { | |
| 82 | + | if h == nil || h.rows == 0 { | |
| 83 | + | return HistoryView{} | |
| 84 | + | } | |
| 85 | + | chunks := append([]*HistoryChunk(nil), h.chunks...) | |
| 86 | + | if len(h.tail) > 0 { | |
| 87 | + | rows := make([][]renderer.Cell, len(h.tail)) | |
| 88 | + | for i, row := range h.tail { | |
| 89 | + | rows[i] = append([]renderer.Cell(nil), row...) | |
| 90 | + | } | |
| 91 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 92 | + | } | |
| 93 | + | return HistoryView{chunks: chunks, rows: h.rows} | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (v HistoryView) Len() int { return v.rows } | |
| 97 | + | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |
| 98 | + | ||
| 99 | + | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |
| 100 | + | func (v HistoryView) Chunk(i int) *HistoryChunk { | |
| 101 | + | if i < 0 || i >= len(v.chunks) { | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | return v.chunks[i] | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Row returns a copy of the row at i, or nil when i is out of range. | |
| 108 | + | func (v HistoryView) Row(i int) []renderer.Cell { | |
| 109 | + | if i < 0 { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | for _, chunk := range v.chunks { | |
| 113 | + | if i < len(chunk.rows) { | |
| 114 | + | return append([]renderer.Cell(nil), chunk.rows[i]...) | |
| 115 | + | } | |
| 116 | + | i -= len(chunk.rows) | |
| 117 | + | } | |
| 118 | + | return nil | |
| 119 | + | } | |
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // HistoryConfig controls the bounded terminal history retained by a Screen. | |||
| 6 | + | type HistoryConfig struct { | |||
| 7 | + | MaxRows int | |||
| 8 | + | ChunkRows int | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // History stores terminal rows in immutable chunks. It is intended to be | |||
| 12 | + | // mutated by the owner of a Screen; views are safe to retain after later | |||
| 13 | + | // appends. | |||
| 14 | + | type History struct { | |||
| 15 | + | maxRows int | |||
| 16 | + | chunkRows int | |||
| 17 | + | chunks []*HistoryChunk | |||
| 18 | + | tail [][]renderer.Cell | |||
| 19 | + | rows int | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // HistoryChunk is an immutable group of history rows. Its identity is stable | |||
| 23 | + | // and can be used by consumers to avoid copying unchanged sealed chunks. | |||
| 24 | + | type HistoryChunk struct { | |||
| 25 | + | rows [][]renderer.Cell | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |||
| 29 | + | // storage behind a sealed chunk remains owned by VT. | |||
| 30 | + | type HistoryView struct { | |||
| 31 | + | chunks []*HistoryChunk | |||
| 32 | + | rows int | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func NewHistory(config HistoryConfig) *History { | |||
| 36 | + | if config.MaxRows <= 0 { | |||
| 37 | + | return &History{} | |||
| 38 | + | } | |||
| 39 | + | chunkRows := config.ChunkRows | |||
| 40 | + | if chunkRows <= 0 { | |||
| 41 | + | chunkRows = 256 | |||
| 42 | + | } | |||
| 43 | + | chunkRows = min(chunkRows, 256) | |||
| 44 | + | chunkRows = min(chunkRows, config.MaxRows) | |||
| 45 | + | return &History{maxRows: config.MaxRows, chunkRows: chunkRows} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Append records a copy of row. Once a chunk is full it is sealed forever. | |||
| 49 | + | func (h *History) Append(row []renderer.Cell) { | |||
| 50 | + | if h == nil || h.maxRows == 0 { | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |||
| 54 | + | h.rows++ | |||
| 55 | + | if len(h.tail) == h.chunkRows { | |||
| 56 | + | h.sealTail() | |||
| 57 | + | } | |||
| 58 | + | h.evict() | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (h *History) sealTail() { | |||
| 62 | + | if len(h.tail) == 0 { | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail}) | |||
| 66 | + | h.tail = nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (h *History) evict() { | |||
| 70 | + | for h.rows > h.maxRows && len(h.chunks) > 0 { | |||
| 71 | + | evicted := h.chunks[0] | |||
| 72 | + | h.rows -= len(evicted.rows) | |||
| 73 | + | copy(h.chunks, h.chunks[1:]) | |||
| 74 | + | h.chunks[len(h.chunks)-1] = nil | |||
| 75 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // View captures the current history. Sealed chunks are shared by identity; a | |||
| 80 | + | // partially-filled tail is copied into a new immutable chunk for this view. | |||
| 81 | + | func (h *History) View() HistoryView { | |||
| 82 | + | if h == nil || h.rows == 0 { | |||
| 83 | + | return HistoryView{} | |||
| 84 | + | } | |||
| 85 | + | chunks := append([]*HistoryChunk(nil), h.chunks...) | |||
| 86 | + | if len(h.tail) > 0 { | |||
| 87 | + | rows := make([][]renderer.Cell, len(h.tail)) | |||
| 88 | + | for i, row := range h.tail { | |||
| 89 | + | rows[i] = append([]renderer.Cell(nil), row...) | |||
| 90 | + | } | |||
| 91 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 92 | + | } | |||
| 93 | + | return HistoryView{chunks: chunks, rows: h.rows} | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (v HistoryView) Len() int { return v.rows } | |||
| 97 | + | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |||
| 98 | + | ||||
| 99 | + | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |||
| 100 | + | func (v HistoryView) Chunk(i int) *HistoryChunk { | |||
| 101 | + | if i < 0 || i >= len(v.chunks) { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | return v.chunks[i] | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Row returns a copy of the row at i, or nil when i is out of range. | |||
| 108 | + | func (v HistoryView) Row(i int) []renderer.Cell { | |||
| 109 | + | if i < 0 { | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | for _, chunk := range v.chunks { | |||
| 113 | + | if i < len(chunk.rows) { | |||
| 114 | + | return append([]renderer.Cell(nil), chunk.rows[i]...) | |||
| 115 | + | } | |||
| 116 | + | i -= len(chunk.rows) | |||
| 117 | + | } | |||
| 118 | + | return nil | |||
| 119 | + | } | |||
A pkg/vt/history_codec.go +217 -0
| @@ -0,0 +1,217 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "math" | |
| 8 | + | "unicode/utf8" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | historyMagic = "VTH1" | |
| 15 | + | historyVersion = 1 | |
| 16 | + | historyCellBytes = 41 | |
| 17 | + | maxHistoryChunkRows = 256 | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var errInvalidHistory = errors.New("invalid history payload") | |
| 21 | + | ||
| 22 | + | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | |
| 23 | + | // format. It preserves chunk boundaries as well as every Cell and Style field. | |
| 24 | + | func MarshalHistory(view HistoryView) ([]byte, error) { | |
| 25 | + | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 { | |
| 26 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 27 | + | } | |
| 28 | + | out := make([]byte, 0, 9) | |
| 29 | + | out = append(out, historyMagic...) | |
| 30 | + | out = append(out, historyVersion) | |
| 31 | + | out = appendUint32(out, uint32(len(view.chunks))) | |
| 32 | + | for _, chunk := range view.chunks { | |
| 33 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows { | |
| 34 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 35 | + | } | |
| 36 | + | out = appendUint32(out, uint32(len(chunk.rows))) | |
| 37 | + | for _, row := range chunk.rows { | |
| 38 | + | if uint64(len(row)) > math.MaxUint32 { | |
| 39 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 40 | + | } | |
| 41 | + | out = appendUint32(out, uint32(len(row))) | |
| 42 | + | for _, cell := range row { | |
| 43 | + | if !validHistoryCell(cell) { | |
| 44 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 45 | + | } | |
| 46 | + | out = appendHistoryCell(out, cell) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return out, nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func historyViewRowCount(view HistoryView) int { | |
| 54 | + | n := 0 | |
| 55 | + | for _, chunk := range view.chunks { | |
| 56 | + | if chunk != nil && len(chunk.rows) <= math.MaxInt-n { | |
| 57 | + | n += len(chunk.rows) | |
| 58 | + | continue | |
| 59 | + | } | |
| 60 | + | return -1 | |
| 61 | + | } | |
| 62 | + | return n | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func appendUint32(dst []byte, v uint32) []byte { | |
| 66 | + | return binary.BigEndian.AppendUint32(dst, v) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |
| 70 | + | style := cell.Style | |
| 71 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune)) | |
| 72 | + | flags := byte(0) | |
| 73 | + | if cell.Continuation { | |
| 74 | + | flags |= 1 << 0 | |
| 75 | + | } | |
| 76 | + | if style.Bold { | |
| 77 | + | flags |= 1 << 1 | |
| 78 | + | } | |
| 79 | + | if style.Italic { | |
| 80 | + | flags |= 1 << 2 | |
| 81 | + | } | |
| 82 | + | if style.Inverse { | |
| 83 | + | flags |= 1 << 3 | |
| 84 | + | } | |
| 85 | + | if style.HasForegroundRGB { | |
| 86 | + | flags |= 1 << 4 | |
| 87 | + | } | |
| 88 | + | if style.HasBackgroundRGB { | |
| 89 | + | flags |= 1 << 5 | |
| 90 | + | } | |
| 91 | + | if style.HasUnderlineColor { | |
| 92 | + | flags |= 1 << 6 | |
| 93 | + | } | |
| 94 | + | if style.HasUnderlineColorRGB { | |
| 95 | + | flags |= 1 << 7 | |
| 96 | + | } | |
| 97 | + | dst = append(dst, flags) | |
| 98 | + | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |
| 99 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |
| 100 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |
| 101 | + | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |
| 102 | + | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |
| 103 | + | dst = append(dst, byte(style.UnderlineStyle)) | |
| 104 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |
| 105 | + | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |
| 109 | + | // malformed declarations, truncated data, and trailing bytes. | |
| 110 | + | func UnmarshalHistory(data []byte) (HistoryView, error) { | |
| 111 | + | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 112 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 113 | + | } | |
| 114 | + | p := historyParser{data: data[5:]} | |
| 115 | + | chunkCount, ok := p.uint32() | |
| 116 | + | if !ok || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 117 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 118 | + | } | |
| 119 | + | chunks := make([]*HistoryChunk, 0, chunkCount) | |
| 120 | + | rowsTotal := 0 | |
| 121 | + | for range chunkCount { | |
| 122 | + | rowCount, ok := p.uint32() | |
| 123 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 { | |
| 124 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 125 | + | } | |
| 126 | + | if rowsTotal > math.MaxInt-int(rowCount) { | |
| 127 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 128 | + | } | |
| 129 | + | rows := make([][]renderer.Cell, 0, rowCount) | |
| 130 | + | for range rowCount { | |
| 131 | + | cellCount, ok := p.uint32() | |
| 132 | + | if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |
| 133 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 134 | + | } | |
| 135 | + | row := make([]renderer.Cell, cellCount) | |
| 136 | + | for i := range row { | |
| 137 | + | cell, ok := p.cell() | |
| 138 | + | if !ok || !validHistoryCell(cell) { | |
| 139 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 140 | + | } | |
| 141 | + | row[i] = cell | |
| 142 | + | } | |
| 143 | + | rows = append(rows, row) | |
| 144 | + | } | |
| 145 | + | rowsTotal += int(rowCount) | |
| 146 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 147 | + | } | |
| 148 | + | if len(p.data) != 0 { | |
| 149 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 150 | + | } | |
| 151 | + | return HistoryView{chunks: chunks, rows: rowsTotal}, nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | type historyParser struct{ data []byte } | |
| 155 | + | ||
| 156 | + | func (p *historyParser) uint32() (uint32, bool) { | |
| 157 | + | if len(p.data) < 4 { | |
| 158 | + | return 0, false | |
| 159 | + | } | |
| 160 | + | v := binary.BigEndian.Uint32(p.data) | |
| 161 | + | p.data = p.data[4:] | |
| 162 | + | return v, true | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (p *historyParser) cell() (renderer.Cell, bool) { | |
| 166 | + | if len(p.data) < historyCellBytes { | |
| 167 | + | return renderer.Cell{}, false | |
| 168 | + | } | |
| 169 | + | b := p.data[:historyCellBytes] | |
| 170 | + | p.data = p.data[historyCellBytes:] | |
| 171 | + | foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15])) | |
| 172 | + | if !ok { | |
| 173 | + | return renderer.Cell{}, false | |
| 174 | + | } | |
| 175 | + | background, ok := historyInt(binary.BigEndian.Uint64(b[15:23])) | |
| 176 | + | if !ok { | |
| 177 | + | return renderer.Cell{}, false | |
| 178 | + | } | |
| 179 | + | underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38])) | |
| 180 | + | if !ok { | |
| 181 | + | return renderer.Cell{}, false | |
| 182 | + | } | |
| 183 | + | flags := b[4] | |
| 184 | + | return renderer.Cell{ | |
| 185 | + | Rune: rune(binary.BigEndian.Uint32(b[0:4])), | |
| 186 | + | Continuation: flags&1 != 0, | |
| 187 | + | Style: renderer.Style{ | |
| 188 | + | Bold: flags&(1<<1) != 0, | |
| 189 | + | Italic: flags&(1<<2) != 0, | |
| 190 | + | Inverse: flags&(1<<3) != 0, | |
| 191 | + | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])), | |
| 192 | + | Foreground: foreground, | |
| 193 | + | Background: background, | |
| 194 | + | HasForegroundRGB: flags&(1<<4) != 0, | |
| 195 | + | ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]}, | |
| 196 | + | HasBackgroundRGB: flags&(1<<5) != 0, | |
| 197 | + | BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]}, | |
| 198 | + | UnderlineStyle: renderer.UnderlineStyle(b[29]), | |
| 199 | + | HasUnderlineColor: flags&(1<<6) != 0, | |
| 200 | + | UnderlineColor: underlineColor, | |
| 201 | + | HasUnderlineColorRGB: flags&(1<<7) != 0, | |
| 202 | + | UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]}, | |
| 203 | + | }, | |
| 204 | + | }, true | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func historyInt(raw uint64) (int, bool) { | |
| 208 | + | v := int64(raw) | |
| 209 | + | if int64(int(v)) != v { | |
| 210 | + | return 0, false | |
| 211 | + | } | |
| 212 | + | return int(v), true | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func validHistoryCell(cell renderer.Cell) bool { | |
| 216 | + | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |
| 217 | + | } | |
| @@ -0,0 +1,217 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "math" | |||
| 8 | + | "unicode/utf8" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | historyMagic = "VTH1" | |||
| 15 | + | historyVersion = 1 | |||
| 16 | + | historyCellBytes = 41 | |||
| 17 | + | maxHistoryChunkRows = 256 | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var errInvalidHistory = errors.New("invalid history payload") | |||
| 21 | + | ||||
| 22 | + | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | |||
| 23 | + | // format. It preserves chunk boundaries as well as every Cell and Style field. | |||
| 24 | + | func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 25 | + | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 { | |||
| 26 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 27 | + | } | |||
| 28 | + | out := make([]byte, 0, 9) | |||
| 29 | + | out = append(out, historyMagic...) | |||
| 30 | + | out = append(out, historyVersion) | |||
| 31 | + | out = appendUint32(out, uint32(len(view.chunks))) | |||
| 32 | + | for _, chunk := range view.chunks { | |||
| 33 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows { | |||
| 34 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 35 | + | } | |||
| 36 | + | out = appendUint32(out, uint32(len(chunk.rows))) | |||
| 37 | + | for _, row := range chunk.rows { | |||
| 38 | + | if uint64(len(row)) > math.MaxUint32 { | |||
| 39 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 40 | + | } | |||
| 41 | + | out = appendUint32(out, uint32(len(row))) | |||
| 42 | + | for _, cell := range row { | |||
| 43 | + | if !validHistoryCell(cell) { | |||
| 44 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 45 | + | } | |||
| 46 | + | out = appendHistoryCell(out, cell) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | return out, nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func historyViewRowCount(view HistoryView) int { | |||
| 54 | + | n := 0 | |||
| 55 | + | for _, chunk := range view.chunks { | |||
| 56 | + | if chunk != nil && len(chunk.rows) <= math.MaxInt-n { | |||
| 57 | + | n += len(chunk.rows) | |||
| 58 | + | continue | |||
| 59 | + | } | |||
| 60 | + | return -1 | |||
| 61 | + | } | |||
| 62 | + | return n | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func appendUint32(dst []byte, v uint32) []byte { | |||
| 66 | + | return binary.BigEndian.AppendUint32(dst, v) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 70 | + | style := cell.Style | |||
| 71 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune)) | |||
| 72 | + | flags := byte(0) | |||
| 73 | + | if cell.Continuation { | |||
| 74 | + | flags |= 1 << 0 | |||
| 75 | + | } | |||
| 76 | + | if style.Bold { | |||
| 77 | + | flags |= 1 << 1 | |||
| 78 | + | } | |||
| 79 | + | if style.Italic { | |||
| 80 | + | flags |= 1 << 2 | |||
| 81 | + | } | |||
| 82 | + | if style.Inverse { | |||
| 83 | + | flags |= 1 << 3 | |||
| 84 | + | } | |||
| 85 | + | if style.HasForegroundRGB { | |||
| 86 | + | flags |= 1 << 4 | |||
| 87 | + | } | |||
| 88 | + | if style.HasBackgroundRGB { | |||
| 89 | + | flags |= 1 << 5 | |||
| 90 | + | } | |||
| 91 | + | if style.HasUnderlineColor { | |||
| 92 | + | flags |= 1 << 6 | |||
| 93 | + | } | |||
| 94 | + | if style.HasUnderlineColorRGB { | |||
| 95 | + | flags |= 1 << 7 | |||
| 96 | + | } | |||
| 97 | + | dst = append(dst, flags) | |||
| 98 | + | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |||
| 99 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |||
| 100 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |||
| 101 | + | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |||
| 102 | + | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |||
| 103 | + | dst = append(dst, byte(style.UnderlineStyle)) | |||
| 104 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |||
| 105 | + | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |||
| 109 | + | // malformed declarations, truncated data, and trailing bytes. | |||
| 110 | + | func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 111 | + | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |||
| 112 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 113 | + | } | |||
| 114 | + | p := historyParser{data: data[5:]} | |||
| 115 | + | chunkCount, ok := p.uint32() | |||
| 116 | + | if !ok || uint64(chunkCount) > uint64(len(p.data))/4 { | |||
| 117 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 118 | + | } | |||
| 119 | + | chunks := make([]*HistoryChunk, 0, chunkCount) | |||
| 120 | + | rowsTotal := 0 | |||
| 121 | + | for range chunkCount { | |||
| 122 | + | rowCount, ok := p.uint32() | |||
| 123 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 { | |||
| 124 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 125 | + | } | |||
| 126 | + | if rowsTotal > math.MaxInt-int(rowCount) { | |||
| 127 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 128 | + | } | |||
| 129 | + | rows := make([][]renderer.Cell, 0, rowCount) | |||
| 130 | + | for range rowCount { | |||
| 131 | + | cellCount, ok := p.uint32() | |||
| 132 | + | if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |||
| 133 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 134 | + | } | |||
| 135 | + | row := make([]renderer.Cell, cellCount) | |||
| 136 | + | for i := range row { | |||
| 137 | + | cell, ok := p.cell() | |||
| 138 | + | if !ok || !validHistoryCell(cell) { | |||
| 139 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 140 | + | } | |||
| 141 | + | row[i] = cell | |||
| 142 | + | } | |||
| 143 | + | rows = append(rows, row) | |||
| 144 | + | } | |||
| 145 | + | rowsTotal += int(rowCount) | |||
| 146 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 147 | + | } | |||
| 148 | + | if len(p.data) != 0 { | |||
| 149 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 150 | + | } | |||
| 151 | + | return HistoryView{chunks: chunks, rows: rowsTotal}, nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | type historyParser struct{ data []byte } | |||
| 155 | + | ||||
| 156 | + | func (p *historyParser) uint32() (uint32, bool) { | |||
| 157 | + | if len(p.data) < 4 { | |||
| 158 | + | return 0, false | |||
| 159 | + | } | |||
| 160 | + | v := binary.BigEndian.Uint32(p.data) | |||
| 161 | + | p.data = p.data[4:] | |||
| 162 | + | return v, true | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 166 | + | if len(p.data) < historyCellBytes { | |||
| 167 | + | return renderer.Cell{}, false | |||
| 168 | + | } | |||
| 169 | + | b := p.data[:historyCellBytes] | |||
| 170 | + | p.data = p.data[historyCellBytes:] | |||
| 171 | + | foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15])) | |||
| 172 | + | if !ok { | |||
| 173 | + | return renderer.Cell{}, false | |||
| 174 | + | } | |||
| 175 | + | background, ok := historyInt(binary.BigEndian.Uint64(b[15:23])) | |||
| 176 | + | if !ok { | |||
| 177 | + | return renderer.Cell{}, false | |||
| 178 | + | } | |||
| 179 | + | underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38])) | |||
| 180 | + | if !ok { | |||
| 181 | + | return renderer.Cell{}, false | |||
| 182 | + | } | |||
| 183 | + | flags := b[4] | |||
| 184 | + | return renderer.Cell{ | |||
| 185 | + | Rune: rune(binary.BigEndian.Uint32(b[0:4])), | |||
| 186 | + | Continuation: flags&1 != 0, | |||
| 187 | + | Style: renderer.Style{ | |||
| 188 | + | Bold: flags&(1<<1) != 0, | |||
| 189 | + | Italic: flags&(1<<2) != 0, | |||
| 190 | + | Inverse: flags&(1<<3) != 0, | |||
| 191 | + | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])), | |||
| 192 | + | Foreground: foreground, | |||
| 193 | + | Background: background, | |||
| 194 | + | HasForegroundRGB: flags&(1<<4) != 0, | |||
| 195 | + | ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]}, | |||
| 196 | + | HasBackgroundRGB: flags&(1<<5) != 0, | |||
| 197 | + | BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]}, | |||
| 198 | + | UnderlineStyle: renderer.UnderlineStyle(b[29]), | |||
| 199 | + | HasUnderlineColor: flags&(1<<6) != 0, | |||
| 200 | + | UnderlineColor: underlineColor, | |||
| 201 | + | HasUnderlineColorRGB: flags&(1<<7) != 0, | |||
| 202 | + | UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]}, | |||
| 203 | + | }, | |||
| 204 | + | }, true | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func historyInt(raw uint64) (int, bool) { | |||
| 208 | + | v := int64(raw) | |||
| 209 | + | if int64(int(v)) != v { | |||
| 210 | + | return 0, false | |||
| 211 | + | } | |||
| 212 | + | return int(v), true | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func validHistoryCell(cell renderer.Cell) bool { | |||
| 216 | + | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |||
| 217 | + | } | |||
M pkg/vt/screen.go +26 -7
| @@ -85,6 +85,8 @@ type Screen struct { | |||
| 85 | 85 | // never invoke it. Nil disables it. | |
| 86 | 86 | OnClipboard func(b64 string) | |
| 87 | 87 | ||
| 88 | + | history *History | |
| 89 | + | ||
| 88 | 90 | defaultFG renderer.RGB | |
| 89 | 91 | defaultBG renderer.RGB | |
| 90 | 92 | defaultColorsKnown bool | |
| @@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen { | |||
| 128 | 130 | return s | |
| 129 | 131 | } | |
| 130 | 132 | ||
| 133 | + | // NewScreenWithHistory creates a screen that records rows evicted from its | |
| 134 | + | // primary screen into bounded immutable terminal history. | |
| 135 | + | func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |
| 136 | + | s := NewScreen(width, height) | |
| 137 | + | s.history = NewHistory(config) | |
| 138 | + | return s | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // History returns this screen's terminal history, or nil when history was not | |
| 142 | + | // configured with NewScreenWithHistory. | |
| 143 | + | func (s *Screen) History() *History { return s.history } | |
| 144 | + | ||
| 131 | 145 | func (s *Screen) Resize(width, height int) { | |
| 132 | 146 | if width == s.Frame.Width && height == s.Frame.Height { | |
| 133 | 147 | return | |
| 134 | 148 | } | |
| 135 | - | evict := func(row []renderer.Cell) { | |
| 136 | - | if s.OnLineEvicted != nil { | |
| 137 | - | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
| 138 | - | } | |
| 139 | - | } | |
| 149 | + | evict := s.recordEvicted | |
| 140 | 150 | ||
| 141 | 151 | if s.alternate != nil { | |
| 142 | 152 | var shift int | |
| @@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||
| 580 | 590 | } | |
| 581 | 591 | ||
| 582 | 592 | func (s *Screen) emitLineEvicted(top, n int) { | |
| 583 | - | if s.OnLineEvicted == nil || s.alternate != nil { | |
| 593 | + | if s.alternate != nil { | |
| 584 | 594 | return | |
| 585 | 595 | } | |
| 586 | 596 | for y := top; y < top+n; y++ { | |
| 587 | - | s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...)) | |
| 597 | + | s.recordEvicted(s.Frame.Row(y)) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func (s *Screen) recordEvicted(row []renderer.Cell) { | |
| 602 | + | if s.history != nil { | |
| 603 | + | s.history.Append(row) | |
| 604 | + | } | |
| 605 | + | if s.OnLineEvicted != nil { | |
| 606 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
| 588 | 607 | } | |
| 589 | 608 | } | |
| 590 | 609 | ||
| @@ -85,6 +85,8 @@ type Screen struct { | |||||
| 85 | // never invoke it. Nil disables it. | 85 | // never invoke it. Nil disables it. | ||
| 86 | OnClipboard func(b64 string) | 86 | OnClipboard func(b64 string) | ||
| 87 | 87 | ||||
| 88 | + | history *History | |||
| 89 | + | ||||
| 88 | defaultFG renderer.RGB | 90 | defaultFG renderer.RGB | ||
| 89 | defaultBG renderer.RGB | 91 | defaultBG renderer.RGB | ||
| 90 | defaultColorsKnown bool | 92 | defaultColorsKnown bool | ||
| @@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen { | |||||
| 128 | return s | 130 | return s | ||
| 129 | } | 131 | } | ||
| 130 | 132 | ||||
| 133 | + | // NewScreenWithHistory creates a screen that records rows evicted from its | |||
| 134 | + | // primary screen into bounded immutable terminal history. | |||
| 135 | + | func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||
| 136 | + | s := NewScreen(width, height) | |||
| 137 | + | s.history = NewHistory(config) | |||
| 138 | + | return s | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // History returns this screen's terminal history, or nil when history was not | |||
| 142 | + | // configured with NewScreenWithHistory. | |||
| 143 | + | func (s *Screen) History() *History { return s.history } | |||
| 144 | + | ||||
| 131 | func (s *Screen) Resize(width, height int) { | 145 | func (s *Screen) Resize(width, height int) { | ||
| 132 | if width == s.Frame.Width && height == s.Frame.Height { | 146 | if width == s.Frame.Width && height == s.Frame.Height { | ||
| 133 | return | 147 | return | ||
| 134 | } | 148 | } | ||
| 135 | - | evict := func(row []renderer.Cell) { | 149 | + | evict := s.recordEvicted |
| 136 | - | if s.OnLineEvicted != nil { | |||
| 137 | - | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | 150 | ||||
| 141 | if s.alternate != nil { | 151 | if s.alternate != nil { | ||
| 142 | var shift int | 152 | var shift int | ||
| @@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) { | |||||
| 580 | } | 590 | } | ||
| 581 | 591 | ||||
| 582 | func (s *Screen) emitLineEvicted(top, n int) { | 592 | func (s *Screen) emitLineEvicted(top, n int) { | ||
| 583 | - | if s.OnLineEvicted == nil || s.alternate != nil { | 593 | + | if s.alternate != nil { |
| 584 | return | 594 | return | ||
| 585 | } | 595 | } | ||
| 586 | for y := top; y < top+n; y++ { | 596 | for y := top; y < top+n; y++ { | ||
| 587 | - | s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...)) | 597 | + | s.recordEvicted(s.Frame.Row(y)) |
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func (s *Screen) recordEvicted(row []renderer.Cell) { | |||
| 602 | + | if s.history != nil { | |||
| 603 | + | s.history.Append(row) | |||
| 604 | + | } | |||
| 605 | + | if s.OnLineEvicted != nil { | |||
| 606 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 588 | } | 607 | } | ||
| 589 | } | 608 | } | ||
| 590 | 609 | ||||