/**
* A file, as a grid of character cells with one byte of derived meaning each.
*
* This is the bridge between the model and the renderer. A span of code becomes
* one quad plus two integer textures — a character grid and a per-character
* semantic byte — which is what makes code affordable INSIDE many expanded nodes
* at once, where an element per character is not. Specified in
* docs/EXPRESSION-FLOOR.md V1-V2. It draws code inside a node; it is NOT a
* substrate that the graph is painted onto (an earlier revision of the spec said
* that, and it was wrong: levels are discrete and the graph is the thing).
*
* The byte is a bitfield, because a cell is often several things at once — a
* `return` inside two nested zones is depth 2 AND an operation AND an exit:
*
* bits 0-2 zone depth, 0-7, SATURATING (a deeper nesting clamps, never wraps)
* bit 3 inside an operation's span
* bit 4 inside an exit's span
* bit 5 inside a named wire's span
*
* Three things codopticon gets wrong that are fixed here, because our own source
* would hit all three: a tab is EXPANDED (GDScript is tab-indented, and a
* one-cell tab renders it unindented), a non-ASCII character becomes an explicit
* placeholder glyph instead of indexing off the end of a 128-entry table
* (codetree's comments are full of em-dashes), and one pathological generated
* line cannot size the whole grid.
*/
export const CELL = {
DEPTH_MASK: 0x07,
OP: 0x08,
EXIT: 0x10,
NAME: 0x20,
// The atlas reserves slot 127 for "a character we cannot draw".
NON_ASCII_GLYPH: 127
}
const DEFAULTS = {
tabWidth: 2,
colLimit: 400
}
/**
* @param {string} source - the file, verbatim
* @param {object} model - what deriveFile() returned for it
* @returns {object} { cols, rows, text, cells, ... } — text and cells are
* Uint8Array(cols*rows), row-major, NUL-padded.
*/
export function buildCells (source, model, opts = {}) {
const tabWidth = opts.tabWidth || DEFAULTS.tabWidth
const colLimit = opts.colLimit || DEFAULTS.colLimit
// --- pass 1: lay characters out into (row, col), expanding tabs.
//
// `cellOf` maps a source offset to its flat cell index, so a model range can
// be painted later without re-deriving anything. -1 means "this offset has no
// cell" — a newline, a carriage return, or a column past the limit.
const cellOf = new Int32Array(source.length).fill(-1)
const colAt = new Int32Array(source.length).fill(-1)
const rowAt = new Int32Array(source.length).fill(-1)
let row = 0
let col = 0
let maxCol = 0
let truncatedLines = 0
let lineTruncated = false
for (let i = 0; i < source.length; i++) {
const ch = source.charCodeAt(i)
if (ch === 10) { // \n
if (col > maxCol) maxCol = col
if (lineTruncated) { truncatedLines++; lineTruncated = false }
row++
col = 0
continue
}
if (ch === 13) continue // \r — never occupies a cell
if (ch === 9) { // tab
const next = Math.floor(col / tabWidth) * tabWidth + tabWidth
col = next
continue
}
if (col >= colLimit) {
lineTruncated = true
col++
continue
}
rowAt[i] = row
colAt[i] = col
col++
}
if (col > maxCol) maxCol = col
if (lineTruncated) truncatedLines++
const rows = row + 1
const cols = Math.max(1, Math.min(maxCol, colLimit))
// --- pass 2: write the glyph grid.
const text = new Uint8Array(cols * rows)
const cells = new Uint8Array(cols * rows)
let nonAscii = 0
for (let i = 0; i < source.length; i++) {
const c = colAt[i]
if (c < 0 || c >= cols) continue
const idx = rowAt[i] * cols + c
cellOf[i] = idx
const ch = source.charCodeAt(i)
if (ch === 32) continue // a space is an empty cell, not a glyph
if (ch < 127) {
text[idx] = ch
} else {
text[idx] = CELL.NON_ASCII_GLYPH
nonAscii++
}
}
// --- pass 3: paint the model onto the grid.
//
// Painting is per-OFFSET, not per-line, so a zone that starts mid-line tints
// only from where it actually starts — the `if` in `} else if (x) {` does not
// claim the whole line.
function paintDepth (start, end, depth) {
const clamped = Math.min(depth, CELL.DEPTH_MASK)
for (let i = start; i < end && i < source.length; i++) {
const idx = cellOf[i]
if (idx < 0) continue
// Deepest wins: an inner zone's tint must not be overwritten by the outer
// zone painting over it afterwards.
if ((cells[idx] & CELL.DEPTH_MASK) < clamped) {
cells[idx] = (cells[idx] & ~CELL.DEPTH_MASK) | clamped
}
}
}
function paintBit (start, end, bit) {
for (let i = start; i < end && i < source.length; i++) {
const idx = cellOf[i]
if (idx < 0) continue
cells[idx] |= bit
}
}
let maxDepth = 0
const zones = model.zones || []
// Shallowest first, so `deepest wins` above needs no second thought.
const ordered = zones.slice().sort((a, b) => a.depth - b.depth)
for (const z of ordered) {
if (z.depth > maxDepth) maxDepth = z.depth
paintDepth(z.range.startByte, z.range.endByte, z.depth)
}
for (const n of model.nodes || []) paintBit(n.range.startByte, n.range.endByte, CELL.OP)
for (const e of model.exits || []) paintBit(e.range.startByte, e.range.endByte, CELL.EXIT)
for (const n of model.names || []) paintBit(n.range.startByte, n.range.endByte, CELL.NAME)
return {
filePath: model.filePath,
language: model.language || null,
cols,
rows,
text,
cells,
tabWidth,
colLimit,
nonAscii,
truncatedLines,
maxDepth,
partial: !!model.partial,
partialReason: model.partialReason || null,
counts: {
zones: zones.length,
nodes: (model.nodes || []).length,
exits: (model.exits || []).length,
names: (model.names || []).length
}
}
}