1package core
2
3import (
4 "fmt"
5 "maps"
6 "math"
7)
8
9// CellSource exposes semantic terminal cells without exposing mutable storage.
10// Implementations may use any physical layout; callers must use Cell for reads.
11type CellSource interface {
12 Columns() int
13 Rows() int
14 Cell(x, y int) Cell
15}
16
17const (
18 // StoredCellLogicalBytes is the deterministic uncompressed size of one
19 // compact cell, independent of the Go allocator and host architecture.
20 StoredCellLogicalBytes uint64 = 16
21 // RowDescriptorLogicalBytes is the deterministic size of one compact row
22 // descriptor.
23 RowDescriptorLogicalBytes uint64 = 4
24 // StyleRecordLogicalBytes is the deterministic size of one canonical
25 // page-local style record.
26 StyleRecordLogicalBytes uint64 = 32
27
28 continuationFlag = uint8(1)
29)
30
31type storedCell struct {
32 rune int32
33 styleID uint32
34 payloadID uint32
35 flags uint8
36}
37
38type styleSlot struct {
39 style Style
40 refs uint32
41 used bool
42}
43
44type cellPage struct {
45 cells []storedCell
46 rows []uint32
47 styles []styleSlot
48 styleIndex map[Style]uint32
49 freeStyles []uint32
50 styleCount uint32
51 payloads []payloadSlot
52 payloadIndex map[CellPayload]uint32
53 freePayloads []uint32
54 payloadBytes uint64
55}
56
57// Frame is a fixed-size semantic grid backed by one compact page. Cells carry
58// page-local style IDs; callers never observe those IDs or the physical row
59// layout. Logical rows are mapped through compact row descriptors so full-width
60// scrolling rotates descriptors instead of copying cells.
61type Frame struct {
62 Width int
63 Height int
64 page *cellPage
65}
66
67func NewFrame(width, height int) Frame {
68 cellCount, ok := frameCellCount(width, height)
69 if !ok {
70 panic("frame cell count exceeds page limit")
71 }
72 page := &cellPage{
73 cells: make([]storedCell, cellCount),
74 rows: make([]uint32, height),
75 styles: []styleSlot{{style: DefaultStyle(), refs: uint32(cellCount), used: true}},
76 styleIndex: map[Style]uint32{DefaultStyle(): 0},
77 styleCount: 1,
78 }
79 for i := range page.cells {
80 page.cells[i].rune = ' '
81 }
82 for y := range page.rows {
83 page.rows[y] = uint32(y * width)
84 }
85 return Frame{Width: width, Height: height, page: page}
86}
87
88// Clone returns an independent compact page preserving physical row rotation
89// and page-local style IDs.
90func (f Frame) Clone() Frame {
91 if f.page == nil {
92 return Frame{Width: f.Width, Height: f.Height}
93 }
94 return Frame{
95 Width: f.Width,
96 Height: f.Height,
97 page: &cellPage{
98 cells: append([]storedCell(nil), f.page.cells...),
99 rows: append([]uint32(nil), f.page.rows...),
100 styles: append([]styleSlot(nil), f.page.styles...),
101 styleIndex: maps.Clone(f.page.styleIndex),
102 freeStyles: append([]uint32(nil), f.page.freeStyles...),
103 styleCount: f.page.styleCount,
104 payloads: append([]payloadSlot(nil), f.page.payloads...),
105 payloadIndex: maps.Clone(f.page.payloadIndex),
106 freePayloads: append([]uint32(nil), f.page.freePayloads...),
107 payloadBytes: f.page.payloadBytes,
108 },
109 }
110}
111
112// Replace replaces f with an independent structural copy of src's compact page.
113// An invalid or empty source leaves the destination unchanged.
114func (f *Frame) Replace(src Frame) {
115 if f == nil {
116 return
117 }
118 if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
119 return
120 }
121 *f = src.Clone()
122}
123
124func frameCellCount(width, height int) (int, bool) {
125 if width < 0 || height < 0 {
126 return 0, false
127 }
128 limit := min(uint64(math.MaxUint32), uint64(math.MaxInt))
129 w, h := uint64(width), uint64(height)
130 if w > limit || h > limit || w != 0 && w*h > limit {
131 return 0, false
132 }
133 return int(w * h), true
134}
135
136func (f Frame) validateStorage() error {
137 if f.page == nil {
138 return fmt.Errorf("missing frame page")
139 }
140 cellCount, ok := frameCellCount(f.Width, f.Height)
141 if !ok {
142 return fmt.Errorf("frame cell count exceeds page limit")
143 }
144 if len(f.page.cells) != cellCount {
145 return fmt.Errorf("invalid cell count: got %d want %d", len(f.page.cells), cellCount)
146 }
147 if len(f.page.rows) != f.Height {
148 return fmt.Errorf("row descriptor count: got %d want %d", len(f.page.rows), f.Height)
149 }
150 return nil
151}
152
153func (f Frame) Validate() error {
154 if f.Width <= 0 || f.Height <= 0 {
155 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
156 }
157 return f.validateStorage()
158}
159
160// CheckInvariants validates row ownership, style references, dictionary keys,
161// and exact reference counts. It is intended for tests and assertions.
162func (f Frame) CheckInvariants() error {
163 if err := f.validateStorage(); err != nil {
164 return err
165 }
166 seenRows := make([]bool, f.Height)
167 refs := make([]uint32, len(f.page.styles))
168 for y, offset := range f.page.rows {
169 if f.Width == 0 {
170 if offset != 0 {
171 return fmt.Errorf("row %d: nonzero offset %d for zero width", y, offset)
172 }
173 continue
174 }
175 if offset%uint32(f.Width) != 0 {
176 return fmt.Errorf("row %d: offset %d is not a multiple of width %d", y, offset, f.Width)
177 }
178 physical := int(offset) / f.Width
179 if physical >= f.Height || seenRows[physical] {
180 return fmt.Errorf("row %d: invalid or duplicate physical row %d", y, physical)
181 }
182 seenRows[physical] = true
183 }
184 for i, cell := range f.page.cells {
185 if int(cell.styleID) >= len(f.page.styles) || !f.page.styles[cell.styleID].used {
186 return fmt.Errorf("cell %d: unresolved style ID %d", i, cell.styleID)
187 }
188 refs[cell.styleID]++
189 }
190 free := make([]bool, len(f.page.styles))
191 for position, id := range f.page.freeStyles {
192 if int(id) >= len(f.page.styles) {
193 return fmt.Errorf("free style entry %d has out-of-range ID %d", position, id)
194 }
195 if f.page.styles[id].used {
196 return fmt.Errorf("free style entry %d identifies used style %d", position, id)
197 }
198 if free[id] {
199 return fmt.Errorf("free style ID %d appears more than once", id)
200 }
201 free[id] = true
202 }
203 var used uint32
204 for id, slot := range f.page.styles {
205 if !slot.used {
206 if slot.refs != 0 {
207 return fmt.Errorf("free style %d has %d references", id, slot.refs)
208 }
209 if !free[id] {
210 return fmt.Errorf("unused style %d is missing from free list", id)
211 }
212 continue
213 }
214 used++
215 if slot.refs != refs[id] {
216 return fmt.Errorf("style %d references = %d want %d", id, slot.refs, refs[id])
217 }
218 if slot.style != slot.style.Canonical() {
219 return fmt.Errorf("style %d is not canonical", id)
220 }
221 if indexed, ok := f.page.styleIndex[slot.style]; !ok || indexed != uint32(id) {
222 return fmt.Errorf("style %d missing from index", id)
223 }
224 }
225 if len(f.page.styles) == 0 || !f.page.styles[0].used || f.page.styles[0].style != DefaultStyle() {
226 return fmt.Errorf("style ID zero is not the default style")
227 }
228 if used != f.page.styleCount || len(f.page.styleIndex) != int(used) {
229 return fmt.Errorf("style count = %d/%d, index entries %d", used, f.page.styleCount, len(f.page.styleIndex))
230 }
231 return f.checkPayloadInvariants()
232}
233
234// LogicalBytes returns deterministic uncompressed page bytes independent of Go
235// pointer size, map capacity, and allocator overhead.
236func (f Frame) LogicalBytes() uint64 {
237 if f.page == nil {
238 return 0
239 }
240 return uint64(len(f.page.cells))*StoredCellLogicalBytes + uint64(len(f.page.rows))*RowDescriptorLogicalBytes + uint64(f.page.styleCount)*StyleRecordLogicalBytes + f.page.payloadBytes
241}
242
243// StyleCount returns the number of live page-local styles, including default ID zero.
244func (f Frame) StyleCount() int {
245 if f.page == nil {
246 return 0
247 }
248 return int(f.page.styleCount)
249}
250
251func (f Frame) Columns() int { return f.Width }
252func (f Frame) Rows() int { return f.Height }
253func (f Frame) Cell(x, y int) Cell {
254 stored := f.page.cells[f.offset(x, y)]
255 return Cell{Rune: rune(stored.rune), Style: f.page.styles[stored.styleID].style, Payload: f.payload(stored.payloadID), Continuation: stored.flags&continuationFlag != 0}
256}
257func (f Frame) At(x, y int) Cell { return f.Cell(x, y) }
258
259func (f Frame) Set(x, y int, cell Cell) {
260 index := f.offset(x, y)
261 payloadID := f.internPayload(cell.Payload)
262 f.releasePayload(f.page.cells[index].payloadID)
263 oldID := f.page.cells[index].styleID
264 styleID := oldID
265 // Repainting a cell usually keeps its style. Compare the canonical value
266 // before hashing it and changing references in the page-local dictionary.
267 if f.page.styles[oldID].style != cell.Style.Canonical() {
268 styleID = f.internStyle(cell.Style)
269 f.releaseStyle(oldID)
270 }
271 flags := uint8(0)
272 if cell.Continuation {
273 flags = continuationFlag
274 }
275 f.page.cells[index] = storedCell{rune: int32(cell.Rune), styleID: styleID, payloadID: payloadID, flags: flags}
276}
277
278func (f Frame) Row(y int) []Cell {
279 if y < 0 || y >= f.Height {
280 panic("frame row out of range")
281 }
282 row := make([]Cell, f.Width)
283 for x := range row {
284 row[x] = f.Cell(x, y)
285 }
286 return row
287}
288
289func (f Frame) WriteRow(y, x int, cells []Cell) int {
290 count := min(len(cells), f.Width-x)
291 for i := range count {
292 f.Set(x+i, y, cells[i])
293 }
294 return count
295}
296
297func (f Frame) CopyRow(y, dst, src, count int) {
298 if count <= 0 || dst == src {
299 return
300 }
301 if dst < src {
302 for i := range count {
303 f.Set(dst+i, y, f.Cell(src+i, y))
304 }
305 return
306 }
307 for i := count - 1; i >= 0; i-- {
308 f.Set(dst+i, y, f.Cell(src+i, y))
309 }
310}
311
312func (f Frame) FillRow(y, start, end int, cell Cell) {
313 for x := max(0, start); x < min(f.Width, end); x++ {
314 f.Set(x, y, cell)
315 }
316}
317
318func (f Frame) ScrollUp(top, bottom, n int) {
319 for ; n > 0; n-- {
320 recycled := f.page.rows[top]
321 copy(f.page.rows[top:bottom], f.page.rows[top+1:bottom+1])
322 f.page.rows[bottom] = recycled
323 f.blankPhysicalRow(recycled)
324 }
325}
326
327func (f Frame) ScrollDown(top, bottom, n int) {
328 for ; n > 0; n-- {
329 recycled := f.page.rows[bottom]
330 copy(f.page.rows[top+1:bottom+1], f.page.rows[top:bottom])
331 f.page.rows[top] = recycled
332 f.blankPhysicalRow(recycled)
333 }
334}
335
336func (f Frame) offset(x, y int) int {
337 if x < 0 || x >= f.Width {
338 panic("frame column out of range")
339 }
340 if y < 0 || y >= f.Height {
341 panic("frame row out of range")
342 }
343 return int(f.page.rows[y]) + x
344}
345
346func (f Frame) blankPhysicalRow(offset uint32) {
347 for x := range f.Width {
348 index := int(offset) + x
349 f.releasePayload(f.page.cells[index].payloadID)
350 oldID := f.page.cells[index].styleID
351 if oldID != 0 {
352 f.page.styles[0].refs++
353 f.releaseStyle(oldID)
354 }
355 f.page.cells[index] = storedCell{rune: ' '}
356 }
357}
358
359func (f Frame) internStyle(style Style) uint32 {
360 style = style.Canonical()
361 if id, ok := f.page.styleIndex[style]; ok {
362 f.page.styles[id].refs++
363 return id
364 }
365 var id uint32
366 if n := len(f.page.freeStyles); n != 0 {
367 id = f.page.freeStyles[n-1]
368 f.page.freeStyles = f.page.freeStyles[:n-1]
369 f.page.styles[id] = styleSlot{style: style, refs: 1, used: true}
370 } else {
371 id = uint32(len(f.page.styles))
372 f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true})
373 }
374 f.page.styleIndex[style] = id
375 f.page.styleCount++
376 return id
377}
378
379func (f Frame) releaseStyle(id uint32) {
380 slot := &f.page.styles[id]
381 slot.refs--
382 if id == 0 || slot.refs != 0 {
383 return
384 }
385 delete(f.page.styleIndex, slot.style)
386 *slot = styleSlot{}
387 f.page.freeStyles = append(f.page.freeStyles, id)
388 f.page.styleCount--
389}