OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / floor / cells.js

183 lines · javascript ref:
/**
 * 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
    }
  }
}