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 // styleCache remembers the most recent raw Style passed to internStyle and
52 // the live slot it resolved to. It is a pure accelerator: it is cleared
53 // whenever that slot is freed, and a fresh page starts with it invalid.
54 // Clone copies it along with the style slots it refers to, so a clone stays
55 // reflect.DeepEqual to its source.
56 styleCache Style
57 styleCacheID uint32
58 styleCacheOK bool
59 payloads []payloadSlot
60 payloadIndex map[CellPayload]uint32
61 freePayloads []uint32
62 payloadBytes uint64
63}
64
65// Frame is a fixed-size semantic grid backed by one compact page. Cells carry
66// page-local style IDs; callers never observe those IDs or the physical row
67// layout. Logical rows are mapped through compact row descriptors so full-width
68// scrolling rotates descriptors instead of copying cells.
69type Frame struct {
70 Width int
71 Height int
72 page *cellPage
73}
74
75func NewFrame(width, height int) Frame {
76 cellCount, ok := frameCellCount(width, height)
77 if !ok {
78 panic("frame cell count exceeds page limit")
79 }
80 page := &cellPage{
81 cells: make([]storedCell, cellCount),
82 rows: make([]uint32, height),
83 styles: []styleSlot{{style: DefaultStyle(), refs: uint32(cellCount), used: true}},
84 styleIndex: map[Style]uint32{DefaultStyle(): 0},
85 styleCount: 1,
86 }
87 for i := range page.cells {
88 page.cells[i].rune = ' '
89 }
90 for y := range page.rows {
91 page.rows[y] = uint32(y * width)
92 }
93 return Frame{Width: width, Height: height, page: page}
94}
95
96// Clone returns an independent compact page preserving physical row rotation
97// and page-local style IDs.
98func (f Frame) Clone() Frame {
99 if f.page == nil {
100 return Frame{Width: f.Width, Height: f.Height}
101 }
102 return Frame{
103 Width: f.Width,
104 Height: f.Height,
105 page: &cellPage{
106 cells: append([]storedCell(nil), f.page.cells...),
107 rows: append([]uint32(nil), f.page.rows...),
108 styles: append([]styleSlot(nil), f.page.styles...),
109 styleIndex: maps.Clone(f.page.styleIndex),
110 freeStyles: append([]uint32(nil), f.page.freeStyles...),
111 styleCount: f.page.styleCount,
112 styleCache: f.page.styleCache,
113 styleCacheID: f.page.styleCacheID,
114 styleCacheOK: f.page.styleCacheOK,
115 payloads: append([]payloadSlot(nil), f.page.payloads...),
116 payloadIndex: maps.Clone(f.page.payloadIndex),
117 freePayloads: append([]uint32(nil), f.page.freePayloads...),
118 payloadBytes: f.page.payloadBytes,
119 },
120 }
121}
122
123// Replace replaces f with an independent structural copy of src's compact page.
124// An invalid or empty source leaves the destination unchanged.
125func (f *Frame) Replace(src Frame) {
126 if f == nil {
127 return
128 }
129 if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
130 return
131 }
132 *f = src.Clone()
133}
134
135func frameCellCount(width, height int) (int, bool) {
136 if width < 0 || height < 0 {
137 return 0, false
138 }
139 limit := min(uint64(math.MaxUint32), uint64(math.MaxInt))
140 w, h := uint64(width), uint64(height)
141 if w > limit || h > limit || w != 0 && w*h > limit {
142 return 0, false
143 }
144 return int(w * h), true
145}
146
147func (f Frame) validateStorage() error {
148 if f.page == nil {
149 return fmt.Errorf("missing frame page")
150 }
151 cellCount, ok := frameCellCount(f.Width, f.Height)
152 if !ok {
153 return fmt.Errorf("frame cell count exceeds page limit")
154 }
155 if len(f.page.cells) != cellCount {
156 return fmt.Errorf("invalid cell count: got %d want %d", len(f.page.cells), cellCount)
157 }
158 if len(f.page.rows) != f.Height {
159 return fmt.Errorf("row descriptor count: got %d want %d", len(f.page.rows), f.Height)
160 }
161 return nil
162}
163
164func (f Frame) Validate() error {
165 if f.Width <= 0 || f.Height <= 0 {
166 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
167 }
168 return f.validateStorage()
169}
170
171// CheckInvariants validates row ownership, style references, dictionary keys,
172// and exact reference counts. It is intended for tests and assertions.
173func (f Frame) CheckInvariants() error {
174 if err := f.validateStorage(); err != nil {
175 return err
176 }
177 seenRows := make([]bool, f.Height)
178 refs := make([]uint32, len(f.page.styles))
179 for y, offset := range f.page.rows {
180 if f.Width == 0 {
181 if offset != 0 {
182 return fmt.Errorf("row %d: nonzero offset %d for zero width", y, offset)
183 }
184 continue
185 }
186 if offset%uint32(f.Width) != 0 {
187 return fmt.Errorf("row %d: offset %d is not a multiple of width %d", y, offset, f.Width)
188 }
189 physical := int(offset) / f.Width
190 if physical >= f.Height || seenRows[physical] {
191 return fmt.Errorf("row %d: invalid or duplicate physical row %d", y, physical)
192 }
193 seenRows[physical] = true
194 }
195 for i, cell := range f.page.cells {
196 if int(cell.styleID) >= len(f.page.styles) || !f.page.styles[cell.styleID].used {
197 return fmt.Errorf("cell %d: unresolved style ID %d", i, cell.styleID)
198 }
199 refs[cell.styleID]++
200 }
201 free := make([]bool, len(f.page.styles))
202 for position, id := range f.page.freeStyles {
203 if int(id) >= len(f.page.styles) {
204 return fmt.Errorf("free style entry %d has out-of-range ID %d", position, id)
205 }
206 if f.page.styles[id].used {
207 return fmt.Errorf("free style entry %d identifies used style %d", position, id)
208 }
209 if free[id] {
210 return fmt.Errorf("free style ID %d appears more than once", id)
211 }
212 free[id] = true
213 }
214 var used uint32
215 for id, slot := range f.page.styles {
216 if !slot.used {
217 if slot.refs != 0 {
218 return fmt.Errorf("free style %d has %d references", id, slot.refs)
219 }
220 if !free[id] {
221 return fmt.Errorf("unused style %d is missing from free list", id)
222 }
223 continue
224 }
225 used++
226 if slot.refs != refs[id] {
227 return fmt.Errorf("style %d references = %d want %d", id, slot.refs, refs[id])
228 }
229 if slot.style != slot.style.Canonical() {
230 return fmt.Errorf("style %d is not canonical", id)
231 }
232 if indexed, ok := f.page.styleIndex[slot.style]; !ok || indexed != uint32(id) {
233 return fmt.Errorf("style %d missing from index", id)
234 }
235 }
236 if len(f.page.styles) == 0 || !f.page.styles[0].used || f.page.styles[0].style != DefaultStyle() {
237 return fmt.Errorf("style ID zero is not the default style")
238 }
239 if used != f.page.styleCount || len(f.page.styleIndex) != int(used) {
240 return fmt.Errorf("style count = %d/%d, index entries %d", used, f.page.styleCount, len(f.page.styleIndex))
241 }
242 return f.checkPayloadInvariants()
243}
244
245// LogicalBytes returns deterministic uncompressed page bytes independent of Go
246// pointer size, map capacity, and allocator overhead.
247func (f Frame) LogicalBytes() uint64 {
248 if f.page == nil {
249 return 0
250 }
251 return uint64(len(f.page.cells))*StoredCellLogicalBytes + uint64(len(f.page.rows))*RowDescriptorLogicalBytes + uint64(f.page.styleCount)*StyleRecordLogicalBytes + f.page.payloadBytes
252}
253
254// StyleCount returns the number of live page-local styles, including default ID zero.
255func (f Frame) StyleCount() int {
256 if f.page == nil {
257 return 0
258 }
259 return int(f.page.styleCount)
260}
261
262func (f Frame) Columns() int { return f.Width }
263func (f Frame) Rows() int { return f.Height }
264func (f Frame) Cell(x, y int) Cell {
265 stored := f.page.cells[f.offset(x, y)]
266 return Cell{Rune: rune(stored.rune), Style: f.page.styles[stored.styleID].style, Payload: f.payload(stored.payloadID), Continuation: stored.flags&continuationFlag != 0}
267}
268func (f Frame) At(x, y int) Cell { return f.Cell(x, y) }
269
270// AppendRowText appends one rune per non-continuation cell of row y to runes,
271// and that cell's column to columns, and returns both extended slices. A zero
272// rune is reported as a space. It reads only runes and flags, so text scans
273// avoid materializing styled cells.
274func (f Frame) AppendRowText(runes []rune, columns []int, y int) ([]rune, []int) {
275 if f.Width == 0 {
276 return runes, columns
277 }
278 start := f.offset(0, y)
279 for x, stored := range f.page.cells[start : start+f.Width] {
280 if stored.flags&continuationFlag != 0 {
281 continue
282 }
283 r := rune(stored.rune)
284 if r == 0 {
285 r = ' '
286 }
287 runes = append(runes, r)
288 columns = append(columns, x)
289 }
290 return runes, columns
291}
292
293// IsContinuation reports whether (x,y) is the right half of a wide pair
294// without materializing the cell.
295func (f Frame) IsContinuation(x, y int) bool {
296 return f.page.cells[f.offset(x, y)].flags&continuationFlag != 0
297}
298
299func (f Frame) Set(x, y int, cell Cell) {
300 index := f.offset(x, y)
301 payloadID := f.internPayload(cell.Payload)
302 f.releasePayload(f.page.cells[index].payloadID)
303 oldID := f.page.cells[index].styleID
304 styleID := oldID
305 // Repainting a cell usually keeps its style. Stored styles are canonical,
306 // so a raw match proves the canonical form matches too; this skips
307 // canonicalization and the dictionary for the common case.
308 if f.page.styles[oldID].style != cell.Style {
309 styleID = f.internStyle(cell.Style)
310 f.releaseStyle(oldID)
311 }
312 flags := uint8(0)
313 if cell.Continuation {
314 flags = continuationFlag
315 }
316 f.page.cells[index] = storedCell{rune: int32(cell.Rune), styleID: styleID, payloadID: payloadID, flags: flags}
317}
318
319func (f Frame) Row(y int) []Cell {
320 if y < 0 || y >= f.Height {
321 panic("frame row out of range")
322 }
323 row := make([]Cell, f.Width)
324 for x := range row {
325 row[x] = f.Cell(x, y)
326 }
327 return row
328}
329
330func (f Frame) WriteRow(y, x int, cells []Cell) int {
331 count := min(len(cells), f.Width-x)
332 for i := range count {
333 f.Set(x+i, y, cells[i])
334 }
335 return count
336}
337
338func (f Frame) CopyRow(y, dst, src, count int) {
339 if count <= 0 || dst == src {
340 return
341 }
342 if dst < src {
343 for i := range count {
344 f.Set(dst+i, y, f.Cell(src+i, y))
345 }
346 return
347 }
348 for i := count - 1; i >= 0; i-- {
349 f.Set(dst+i, y, f.Cell(src+i, y))
350 }
351}
352
353func (f Frame) FillRow(y, start, end int, cell Cell) {
354 for x := max(0, start); x < min(f.Width, end); x++ {
355 f.Set(x, y, cell)
356 }
357}
358
359func (f Frame) ScrollUp(top, bottom, n int) {
360 for ; n > 0; n-- {
361 recycled := f.page.rows[top]
362 copy(f.page.rows[top:bottom], f.page.rows[top+1:bottom+1])
363 f.page.rows[bottom] = recycled
364 f.blankPhysicalRow(recycled)
365 }
366}
367
368func (f Frame) ScrollDown(top, bottom, n int) {
369 for ; n > 0; n-- {
370 recycled := f.page.rows[bottom]
371 copy(f.page.rows[top+1:bottom+1], f.page.rows[top:bottom])
372 f.page.rows[top] = recycled
373 f.blankPhysicalRow(recycled)
374 }
375}
376
377func (f Frame) offset(x, y int) int {
378 if x < 0 || x >= f.Width {
379 panic("frame column out of range")
380 }
381 if y < 0 || y >= f.Height {
382 panic("frame row out of range")
383 }
384 return int(f.page.rows[y]) + x
385}
386
387func (f Frame) blankPhysicalRow(offset uint32) {
388 row := f.page.cells[offset : int(offset)+f.Width]
389 for i := range row {
390 cell := &row[i]
391 if cell.payloadID != 0 {
392 f.releasePayload(cell.payloadID)
393 }
394 if oldID := cell.styleID; oldID != 0 {
395 f.page.styles[0].refs++
396 f.releaseStyle(oldID)
397 }
398 }
399 if len(row) == 0 {
400 return
401 }
402 row[0] = storedCell{rune: ' '}
403 for filled := 1; filled < len(row); filled *= 2 {
404 copy(row[filled:], row[:filled])
405 }
406}
407
408// internStyle returns the page-local ID for style and acquires one reference.
409func (f Frame) internStyle(raw Style) uint32 {
410 p := f.page
411 if p.styleCacheOK && raw == p.styleCache {
412 p.styles[p.styleCacheID].refs++
413 return p.styleCacheID
414 }
415 style := raw.Canonical()
416 id, ok := p.styleIndex[style]
417 if ok {
418 p.styles[id].refs++
419 } else {
420 if n := len(p.freeStyles); n != 0 {
421 id = p.freeStyles[n-1]
422 p.freeStyles = p.freeStyles[:n-1]
423 p.styles[id] = styleSlot{style: style, refs: 1, used: true}
424 } else {
425 id = uint32(len(p.styles))
426 if len(p.styles) == cap(p.styles) {
427 p.styles = f.growStyles()
428 }
429 p.styles = append(p.styles, styleSlot{style: style, refs: 1, used: true})
430 }
431 p.styleIndex[style] = id
432 p.styleCount++
433 }
434 p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true
435 return id
436}
437
438// growStyles doubles style capacity instead of Go's gentler large-slice growth,
439// roughly halving total bytes allocated by high-cardinality frames. Capacity
440// never exceeds the one-style-per-cell bound, so small frames stay small.
441func (f Frame) growStyles() []styleSlot {
442 old := f.page.styles
443 limit := len(f.page.cells) + 1
444 newCap := min(max(2*cap(old), 4), max(limit, len(old)+1))
445 grown := make([]styleSlot, len(old), newCap)
446 copy(grown, old)
447 return grown
448}
449
450func (f Frame) releaseStyle(id uint32) {
451 slot := &f.page.styles[id]
452 slot.refs--
453 if id == 0 || slot.refs != 0 {
454 return
455 }
456 if f.page.styleCacheID == id {
457 f.page.styleCacheOK = false
458 }
459 delete(f.page.styleIndex, slot.style)
460 *slot = styleSlot{}
461 f.page.freeStyles = append(f.page.freeStyles, id)
462 f.page.styleCount--
463}