text2d source

← text2d · API reference · version 0.4.6

7 file(s), 1124 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
GLYPH_W const15—47 48 71 96 150 173 … (10 total)
GLYPH_H const16—31 45 75 150 174 178 … (7 total)
face_codes fn19—25
face_rows fn29—44
blit_glyph fn40—95 179
text_width fn67TXD-001105 109 25 34 41 42 … (13 total)
line_height fn75—106 273 107 45
write_text fn90—107 70 101 23 32 51 … (14 total)
write_centred fn103—98
Atlas struct120—139 149 158 164 167 188 … (7 total)
Quad struct130—188 189 193 200 58 59 … (8 total)
atlas_new fn139—24 44 55 72 85 97 … (8 total)
atlas_glyph_count fn158—39 44
atlas_writes fn164TXD-00328 37 38 46 48 50 … (8 total)
atlas_cell fn167—192
layout fn188—27 35 45 47 49 56 … (13 total)
draw_quads fn200—74 91 99
Metrics struct233—260 262 264 271 272 278 … (17 total)
metrics_measured fn259—29 30 45 58 73 88 … (9 total)
metrics_builtin fn271—104 119 128 143 21 32 … (18 total)
advance64 fn278—46 89
advance fn284—243 332 36 37 47 65 … (9 total)
height fn287—174 244 48 91 107 45
is_mono fn291—31 32 90 105
width fn304—173 312 331 432 440 474 … (14 total)
fits fn311—120 121 129 34 35 102
fit_text fn329—129 130 131 144 145 152 … (11 total)
ALIGN_LEFT const355—146 156
ALIGN_CENTRE const356—476 147 156
ALIGN_RIGHT const357—477 148 156
take_chars fn362TXD-002340 342 442 31
wrap fn397—28 29 30 31 32 33 … (17 total)
align_x fn472—146 147 148 157

src/face.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The built-in 5x7 face, as DATA.  Generated art, kept here so the library needs
// no file, no `#native` and no GL to put a legible character on a canvas.
//
// 56 glyphs: space, digits, A-Z and common punctuation.  A LOWERCASE letter
// renders as its uppercase form — this is a FALLBACK face whose job is that text
// is never invisible, not that it is beautiful; a real typeface arrives through
// the asset pack.  Anything with no glyph renders as a blank of the same width, so
// a string never changes length because a character was missing.
//
// Each glyph is 7 rows of 5 bits, most significant bit leftmost.

pub const GLYPH_W: integer = 5;
pub const GLYPH_H: integer = 7;

// The codepoints, ascending — `face_row` bisects this.
pub fn face_codes() -> vector<integer> {
  [
    32, 33, 35, 37, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
    51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 65, 66, 67,
    68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83,
    84, 85, 86, 87, 88, 89, 90, 95
  ]
}

// Seven rows per glyph, in the same order as `face_codes`.
pub fn face_rows() -> vector<integer> {
  [
    0, 0, 0, 0, 0, 0, 0, 4, 4, 4, 4, 4, 0, 4, 10, 31,
    10, 10, 31, 10, 0, 17, 18, 4, 4, 4, 9, 17, 4, 4, 0, 0,
    0, 0, 0, 2, 4, 8, 8, 8, 4, 2, 8, 4, 2, 2, 2, 4,
    8, 0, 21, 14, 31, 14, 21, 0, 0, 4, 4, 31, 4, 4, 0, 0,
    0, 0, 0, 6, 6, 8, 0, 0, 0, 31, 0, 0, 0, 0, 0, 0,
    0, 0, 6, 6, 1, 1, 2, 4, 8, 16, 16, 14, 17, 19, 21, 25,
    17, 14, 4, 12, 4, 4, 4, 4, 14, 14, 17, 1, 2, 4, 8, 31,
    31, 2, 4, 2, 1, 17, 14, 2, 6, 10, 18, 31, 2, 2, 31, 16,
    30, 1, 1, 17, 14, 6, 8, 16, 30, 17, 17, 14, 31, 1, 2, 4,
    8, 8, 8, 14, 17, 17, 14, 17, 17, 14, 14, 17, 17, 15, 1, 2,
    12, 0, 6, 6, 0, 6, 6, 0, 0, 6, 6, 0, 6, 6, 8, 2,
    4, 8, 16, 8, 4, 2, 0, 0, 31, 0, 31, 0, 0, 8, 4, 2,
    1, 2, 4, 8, 14, 17, 1, 2, 4, 0, 4, 14, 17, 17, 31, 17,
    17, 17, 30, 17, 17, 30, 17, 17, 30, 14, 17, 16, 16, 16, 17, 14,
    28, 18, 17, 17, 17, 18, 28, 31, 16, 16, 30, 16, 16, 31, 31, 16,
    16, 30, 16, 16, 16, 14, 17, 16, 19, 17, 17, 15, 17, 17, 17, 31,
    17, 17, 17, 14, 4, 4, 4, 4, 4, 14, 1, 1, 1, 1, 17, 17,
    14, 17, 18, 20, 24, 20, 18, 17, 16, 16, 16, 16, 16, 16, 31, 17,
    27, 21, 17, 17, 17, 17, 17, 25, 21, 19, 17, 17, 17, 14, 17, 17,
    17, 17, 17, 14, 30, 17, 17, 30, 16, 16, 16, 14, 17, 17, 17, 21,
    18, 13, 30, 17, 17, 30, 20, 18, 17, 15, 16, 16, 14, 1, 1, 30,
    31, 4, 4, 4, 4, 4, 4, 17, 17, 17, 17, 17, 17, 14, 17, 17,
    17, 17, 17, 10, 4, 17, 17, 17, 17, 21, 27, 17, 17, 17, 10, 4,
    10, 17, 17, 17, 17, 10, 4, 4, 4, 4, 31, 1, 2, 4, 8, 16,
    31, 0, 0, 0, 0, 0, 0, 31
  ]
}

src/text2d.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// text2d — text that always draws.
//
// ⚠ WHY A BUILT-IN FACE EXISTS AT ALL.  `graphics`' `draw_text` needs three things
// at once: a GL context, the native rasteriser, and a font FILE.  Under `loft
// test` the rasteriser answers *"native function not loaded"*, so a repo that
// tests its UI headlessly answers by having no text — and two shipped surfaces in
// `dryopea` did exactly that: `hud.loft` draws its digits as RECTANGLES and
// `picker.loft` shipped without labels, each having reached that conclusion on its
// own.  A face baked in as data needs none of the three.
//
// This is a FALLBACK, and the word is load-bearing: its job is that text is never
// invisible, not that it is beautiful.  A real typeface arrives through the asset
// pack (@PLN146 F5/F6).
use graphics::*;
pub use face::*;

// Where the glyph for `code` starts in `face_rows`, or -1 if there is none.
// Lowercase folds to uppercase — see the note in `face.loft`.
fn glyph_at(code: integer) -> integer {
  c = code;
  if c >= 97 and c <= 122 { c = c - 32; }
  codes = face::face_codes();
  lo = 0;
  hi = len(codes) - 1;
  while lo <= hi {
    mid = (lo + hi) / 2 ?? 0;
    at = codes[mid] ?? -1;
    if at == c { return mid * face::GLYPH_H; }
    if at < c { lo = mid + 1; } else { hi = mid - 1; }
  }
  -1
}

// Blit one codepoint's glyph with its top-left at (x, y).  Shared by the direct
// blitter and by the atlas, so the bit-walk has ONE home and the two can never
// disagree about what a glyph looks like.
pub fn blit_glyph(cv: graphics::Canvas, code: integer, x: integer, y: integer,
                  colour: integer, scale: integer) {
  at = glyph_at(code);
  if at < 0 { return; }
  rows = face::face_rows();
  for ry in 0..face::GLYPH_H {
    bits = rows[at + ry] ?? 0;
    for rx in 0..face::GLYPH_W {
      if (bits & (1 << (face::GLYPH_W - 1 - rx))) != 0 {
        for sy in 0..scale {
          for sx in 0..scale {
            cv.set_pixel(x + rx * scale + sx, y + ry * scale + sy, colour);
          }
        }
      }
    }
  }
}

// The scale a glyph is drawn at: below 1 draws at 1.  Every measurement reads it
// too, so what is measured is what is drawn.
fn drawn_scale(scale: integer) -> integer { if scale < 1 { 1 } else { scale } }

// Width in pixels of `s` at `scale`, INCLUDING the gap after every glyph but the
// last — the ink extent.  Exact: it is what `write_text` answers, so the caller can
// centre with it and be right.
// Example: @TXD-001
pub fn text_width(s: text, scale: integer) -> integer {
  n = 0;
  for _c in s { n = n + 1; }
  if n == 0 { return 0; }
  (n * (face::GLYPH_W + 1) - 1) * drawn_scale(scale)
}

// Height of one line at `scale`.
pub fn line_height(scale: integer) -> integer { face::GLYPH_H * drawn_scale(scale) }

// ⚠ NAMED `write_text`, NOT `draw_text`.  `graphics` already has `draw_text` as a
// METHOD on `Canvas`, and a name has one body per receiver type: a free
// `draw_text(cv: Canvas, …)` here would be refused as a redefinition of it.
//
// Draw `s` with its top-left at (x, y).  Answers the width drawn — `text_width(s,
// scale)`, 0 for an empty string — so a caller can lay out the next run without
// measuring twice.
//
// ⚠ Iterates CHARACTERS, not bytes.  `len(text)` counts characters and an indexed
// read is bytes, so a byte loop truncates multi-byte text silently — the trap the
// strict-index lint exists for.  A character with no glyph draws a blank of the
// same width rather than being skipped, so a string never changes length because
// one character was missing.
pub fn write_text(cv: graphics::Canvas, s: text, x: integer, y: integer,
                 colour: integer, scale: integer) -> integer {
  sc = drawn_scale(scale);
  pen = x;
  for ch in s {
    blit_glyph(cv, ch as integer, pen, y, colour, sc);
    pen = pen + (face::GLYPH_W + 1) * sc;
  }
  if pen == x { 0 } else { pen - x - sc }
}

// Draw `s` centred in the box (bx, by, bw, bh).  The commonest thing a HUD wants,
// and the commonest place an off-by-one lands.
pub fn write_centred(cv: graphics::Canvas, s: text, bx: integer, by: integer,
                    bw: integer, bh: integer, colour: integer, scale: integer) -> integer {
  w = text_width(s, scale);
  h = line_height(scale);
  write_text(cv, s, bx + (bw - w) / 2 ?? 0, by + (bh - h) / 2 ?? 0, colour, scale)
}

// ── B1: the glyph atlas ──────────────────────────────────────────────────────
//
// The point of an atlas here is not speed at the blit — it is that changing what a
// label SAYS costs nothing.  `create_text_texture` bakes one GPU texture per string
// per size per colour, so a score that ticks uploads a texture every frame, and
// Brick Buster pre-baked digits 0-9 and one texture per level numeral to avoid it.
// A glyph sheet is written once; a new string only re-lays-out quads over it.

// A glyph atlas: a canvas of glyph cells, the codepoint each cell holds, the scale they
// were drawn at, and how many times the sheet itself changed (`a_writes`).
pub struct Atlas {
  a_cv: graphics::Canvas,
  a_codes: vector<integer>,   // codepoint held in each cell, in cell order
  a_scale: integer,
  a_cols: integer,
  a_next: integer,            // the next free cell
  a_writes: integer,          // how many times the SHEET changed — the gate reads this
}

// One glyph placed on a line: where it goes, and which cell it comes from.
pub struct Quad {
  pub q_x: integer,
  pub q_y: integer,
  pub q_cell: integer,
}

// A sheet with room for `capacity` glyphs at `scale`.  Shelf-packed in a grid,
// which is enough for a fixed-width face — a proportional one would want a real
// packer, and would still not change what the caller sees.
pub fn atlas_new(scale: integer, capacity: integer) -> Atlas {
  sc = if scale < 1 { 1 } else { scale };
  cap = if capacity < 1 { 1 } else { capacity };
  // ⚠ The sheet holds exactly `capacity`, not the next multiple of a row width.
  // Rounding up would have made `atlas_new(1, 2)` hold sixteen — a caller asking
  // for a small sheet and silently getting a large one is the kind of generosity
  // that hides a full-sheet path until a consumer meets it.
  cols = if cap < 16 { cap } else { 16 };
  rows = (cap + cols - 1) / cols ?? 1;
  if rows < 1 { rows = 1; }
  Atlas {
    a_cv: graphics::canvas(cols * face::GLYPH_W * sc, rows * face::GLYPH_H * sc, 0),
    a_codes: [], a_scale: sc, a_cols: cols, a_next: 0, a_writes: 0,
  }
}

// How many distinct glyphs the sheet holds.  It rises only when a code is met
// for the FIRST time, so it measures what the atlas actually cost rather than
// how much text has been drawn through it.
pub fn atlas_glyph_count(self: const Atlas) -> integer { self.a_next }

// How many times the sheet has been WRITTEN.  A GL consumer uploads on exactly
// these, so a caller can assert that a changing label costs no upload — which is
// the property the whole arc exists for.
// Example: @TXD-003
pub fn atlas_writes(self: const Atlas) -> integer { self.a_writes }

// The cell holding `code`, rasterising it on first use.  -1 when the sheet is full.
pub fn atlas_cell(self: Atlas, code: integer) -> integer {
  c = code;
  if c >= 97 and c <= 122 { c = c - 32; }
  for i in 0..len(self.a_codes) {
    if (self.a_codes[i] ?? -1) == c { return i; }
  }
  cap = (self.a_cv.width / (face::GLYPH_W * self.a_scale) ?? 1)
      * (self.a_cv.height / (face::GLYPH_H * self.a_scale) ?? 1);
  if self.a_next >= cap { return -1; }
  cell = self.a_next;
  cx = (cell % self.a_cols ?? 0) * face::GLYPH_W * self.a_scale;
  cy = (cell / self.a_cols ?? 0) * face::GLYPH_H * self.a_scale;
  blit_glyph(self.a_cv, c, cx, cy, 0xffffffff, self.a_scale);
  self.a_codes += [c];
  self.a_next = cell + 1;
  self.a_writes = self.a_writes + 1;
  cell
}

// Lay a string out over the sheet.  Answers one quad per character; NOTHING is
// written to the sheet unless a character is new to it.
pub fn layout(self: Atlas, s: text, x: integer, y: integer) -> vector<Quad> {
  out: vector<Quad> = [];
  pen = x;
  for ch in s {
    cell = atlas_cell(self, ch as integer);
    out += [Quad { q_x: pen, q_y: y, q_cell: cell }];
    pen = pen + (face::GLYPH_W + 1) * self.a_scale;
  }
  out
}

// Blit laid-out quads from the sheet onto a canvas.
pub fn draw_quads(cv: graphics::Canvas, atlas: const Atlas, quads: const vector<Quad>,
                  colour: integer) {
  gw = face::GLYPH_W * atlas.a_scale;
  gh = face::GLYPH_H * atlas.a_scale;
  for q in quads {
    if q.q_cell < 0 { continue; }
    sx = (q.q_cell % atlas.a_cols ?? 0) * gw;
    sy = (q.q_cell / atlas.a_cols ?? 0) * gh;
    for py in 0..gh {
      for px in 0..gw {
        if atlas.a_cv.get_pixel(sx + px, sy + py) != 0 {
          cv.set_pixel(q.q_x + px, q.q_y + py, colour);
        }
      }
    }
  }
}


// ── B1m: the metrics seam ────────────────────────────────────────────────────
//
// This package has no GL context and no font file, so it cannot measure a real
// face itself.  The consumer measures ONCE at startup through whichever backend
// resolved and hands the numbers over; everything a layout needs is derived
// here.  That keeps `text2d` headless — the property `B0` exists for — while
// still serving a game that ships a real TTF.

// What the consumer measured.
//
// ⚠ `m_mono` IS A FACT ABOUT THE FACE THAT RESOLVED, not the one that was asked
// for.  Requesting `DejaVuSansMono.ttf` gives a fixed-pitch face on the desktop
// and a PROPORTIONAL fallback in the browser, because the browser resolves the
// base name to a CSS family it does not know.  Only a measurement can tell.
pub struct Metrics {
    // ⚠ THE ADVANCE IS IN 1/64 PIXEL, and a whole-pixel field is what this
    // replaces.  DejaVu Sans Mono at 16 px advances 9.6 px, which truncates to
    // 9 — and the error is per CHARACTER, so it accumulates: a 60-character
    // line comes out 36 px short.  Under-estimating is the dangerous direction,
    // because a fit check then believes more fits than does and the text
    // overflows the box it was just proved to fit, silently.
    //
    // 1/64 is the unit fonts are hinted in, and it keeps the residual under one
    // pixel over any line a panel can hold.
    m_adv64: integer,        // advance in 1/64 px
    m_line_h: integer,       // line height in whole px
    m_mono: boolean,         // is the resolved face actually fixed-pitch?
}

// Build metrics from two runs the consumer measured: `n` copies of a WIDE
// character and `n` copies of a NARROW one.
//
// ⚠ THE TWO RUNS ARE THE WHOLE INSTRUMENT, and one width cannot replace them.
// Ten M's are ten M's in any font, so a single measurement gives the advance of
// an M and nothing about whether the face is fixed-pitch.  Two same-length,
// opposite-width strings answer it in one comparison.
//
// The advance is taken from the WIDE run: under fixed pitch the two agree, and
// when they do not, the wider one is the safe basis for a fits-in-the-box
// question — it over-estimates rather than overflowing.
pub fn metrics_measured(wide_run: float, narrow_run: float, n: integer,
                        line_h: integer) -> Metrics {
    if n <= 0 {
        return Metrics { m_adv64: 0, m_line_h: line_h, m_mono: false };
    }
    Metrics { m_adv64: ((wide_run * 64.0 / (n as float)) ?? 0.0) as integer,
              m_line_h: line_h,
              m_mono: wide_run == narrow_run }
}

// The built-in face's own metrics, so a layout written against this seam works
// with no font file at all — the property `B0` exists for, carried forward.
pub fn metrics_builtin(scale: integer) -> Metrics {
    Metrics { m_adv64: (face::GLYPH_W + 1) * scale * 64,
              m_line_h: line_height(scale),
              m_mono: true }
}

// The advance in 1/64 px, as measured.
pub fn advance64(self: const Metrics) -> integer { self.m_adv64 }

// The advance in whole pixels, for a caller that wants to show it.
//
// ⚠ DERIVED, NOT A SECOND FIELD.  One quantity stored twice is how two numbers
// describing one distance drift apart the moment either is edited.
pub fn advance(self: const Metrics) -> integer { (self.m_adv64 + 32) / 64 ?? 0 }

// The line height in pixels — what to advance y by between two lines.
pub fn height(self: const Metrics) -> integer { self.m_line_h }

// Is the pitch fixed?  True is what makes `width` exact instead of an
// over-estimate, so a caller laying out columns can trust the number.
pub fn is_mono(self: const Metrics) -> boolean { self.m_mono }

// The ADVANCE width of `s` — what to reserve a box by.
//
// ⚠ ROUNDED UP.  The residual is a fraction of a pixel per character, and the
// safe direction to spend it is outwards: an over-estimate reserves a pixel too
// many and the text still fits, an under-estimate spills out of a box that was
// just measured as fitting.  Exact under fixed pitch, an over-estimate
// otherwise — the same reason the advance comes from the wide run.
//
// Distinct from `text_width`, which is the built-in face's INK extent: the two
// differ by the one trailing gap the ink extent leaves off, and they answer
// different questions — centre by the ink, reserve by the advance.
pub fn width(self: const Metrics, s: text) -> integer {
    (len(s) * self.m_adv64 + 63) / 64 ?? 0
}

// Does `s` fit in `px`?  Asks the ADVANCE width, which rounds outwards, so a
// true here means drawing it will not spill — the direction a caller needs.
// `fit_text` is what to reach for when the answer is no.
pub fn fits(self: const Metrics, s: text, px: integer) -> boolean {
    width(self, s) <= px
}

// The longest prefix of `s` that fits in `px`, with ".." marking the cut.
//
// ⚠ AN UNTOUCHED STRING COMES BACK UNTOUCHED, so a caller cannot tell "fitted"
// from "never measured" by looking for the marker — it appears only when
// something was actually removed.
//
// ".." rather than a single "…": the ellipsis is one code point but several
// bytes, and a marker whose own width is ambiguous is the wrong thing to measure
// a truncation with.
//
// ⚠ CUTS BY CHARACTERS.  This shipped in B1m as `s[0..keep]`, a BYTE range given
// a character count — safe, because loft snaps a byte cut outward to a character
// boundary, but wrong: a non-ASCII label came back shorter than it needed to be.
// B2's `take_chars` is the cure and the trap is the same one wrapping has.
pub fn fit_text(self: const Metrics, s: text, px: integer) -> text {
    if self.m_adv64 <= 0 { return s; }
    if width(self, s) <= px { return s; }
    adv = advance(self);
    if adv <= 0 { return s; }
    // Room for the marker itself.  Below that there is no honest answer, so it
    // gives back as much as fits and no marker.
    keep = (px / adv ?? 0) - 2;
    if keep <= 0 {
        room = px / adv ?? 0;
        if room <= 0 { return ""; }
        return take_chars(s, room);
    }
    take_chars(s, keep) + ".."
}


// ── B2: wrapping and alignment ───────────────────────────────────────────────
//
// ⚠ `len(text)` COUNTS CHARACTERS; `s[a..b]` SLICES BYTES.  `"héllo"[0..5]` is
// `"héll"` — four characters, not five.  loft snaps a byte cut outward to a
// character boundary, so the failure is not mojibake but something quieter: a
// break computed as a character count and applied as a byte range fits FEWER
// characters than it measured, silently, and only in text that is not ASCII.
// Everything here counts and cuts in characters for that reason.

pub const ALIGN_LEFT: integer = 0;
pub const ALIGN_CENTRE: integer = 1;
pub const ALIGN_RIGHT: integer = 2;

// The first `n` CHARACTERS of `s`.  The cure for the trap above, and public
// because every caller that slices text by a measured count needs it.
// Example: @TXD-002
pub fn take_chars(s: text, n: integer) -> text {
    if n <= 0 { return ""; }
    out = "";
    k = 0;
    for c in s {
        if k >= n { break; }
        out += c;
        k = k + 1;
    }
    out
}

// How many characters of `s` fit in `px`.
//
// Computed rather than searched: a width is `ceil(chars * adv64 / 64)`, which is
// monotonic in the count, so the largest count that fits is a division.  Never
// answers zero — see `wrap`.
fn chars_fitting(self: const Metrics, s: text, px: integer) -> integer {
    if self.m_adv64 <= 0 { return len(s); }
    k = (px * 64) / self.m_adv64 ?? 0;
    if k < 1 { k = 1; }
    if k > len(s) { k = len(s); }
    k
}

// Break `s` into lines that each fit `px`, greedily, on measured advances.
//
// ⚠ A LINE ALWAYS TAKES AT LEAST ONE CHARACTER.  In a box narrower than the
// narrowest glyph the alternatives are an infinite loop or dropping the text,
// and a line that overflows and says so is better than either.
//
// ⚠ AND THE RESULTING TABLE IS PER TARGET.  Native measures a real TTF, the
// browser measures whatever family resolved, so the same string breaks in
// different places.  The invariant that travels is self-consistency: every line
// here fits the box THESE metrics measured.
pub fn wrap(self: const Metrics, s: text, px: integer) -> vector<text> {
    lines: vector<text> = [];
    line = "";
    word = "";
    for c in s {
        if c == '\n' {
            lines += [flush_word(self, line, word, px, lines)];
            // `flush_word` returns the line it could not place; a hard break
            // ends the line whatever is left on it.
            line = "";
            word = "";
            continue;
        }
        if c == ' ' {
            line = place(self, line, word, px, lines);
            word = "";
            continue;
        }
        word += c;
    }
    line = place(self, line, word, px, lines);
    // ⚠ The open line is pushed unless it is empty AND something was already
    // pushed: a hard break that consumed the whole word leaves nothing open, and
    // a trailing blank line is a real one to a caller drawing carets or counting
    // height.  An EMPTY document still gets its single empty line.
    if line != "" or len(lines) == 0 { lines += [line]; }
    lines
}

// Put `word` onto `line`, pushing completed lines into `lines`, and answer the
// line that is still open.
fn place(self: const Metrics, line: text, word: text, px: integer,
         lines: vector<text>) -> text {
    if word == "" { return line; }
    cand = if line == "" { word } else { line + " " + word };
    if width(self, cand) <= px { return cand; }
    open = line;
    if open != "" {
        lines += [open];
        open = "";
    }
    // The word alone, hard-broken if it cannot fit even on an empty line.
    rest = word;
    while width(self, rest) > px {
        k = chars_fitting(self, rest, px);
        lines += [take_chars(rest, k)];
        rest = drop_chars(rest, k);
    }
    rest
}

// The line `flush_word` leaves open is always empty — an explicit break ends the
// line — but the words before it still have to be placed.
fn flush_word(self: const Metrics, line: text, word: text, px: integer,
              lines: vector<text>) -> text {
    place(self, line, word, px, lines)
}

// Everything after the first `n` CHARACTERS.
fn drop_chars(s: text, n: integer) -> text {
    out = "";
    k = 0;
    for c in s {
        if k >= n { out += c; }
        k = k + 1;
    }
    out
}

// Where to start drawing `line` inside a box at `bx` of width `bw`.
//
// ⚠ A LINE WIDER THAN ITS BOX STARTS AT THE BOX.  Centring or right-aligning it
// arithmetically would push its beginning off the left edge, where it cannot be
// clipped back into view — overflow to the right is recoverable, overflow to the
// left loses the start of the text.
pub fn align_x(self: const Metrics, line: text, bx: integer, bw: integer,
               align: integer) -> integer {
    slack = bw - width(self, line);
    if slack <= 0 { return bx; }
    if align == ALIGN_CENTRE { return bx + (slack / 2 ?? 0); }
    if align == ALIGN_RIGHT { return bx + slack; }
    bx
}

tests/atlas.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 B1 — the glyph atlas, and the property the whole arc exists for:
// CHANGING WHAT A LABEL SAYS COSTS NOTHING.
//
// `create_text_texture` bakes one GPU texture per string, per size, per colour, so
// a score that ticks uploads a texture every frame — which is why Brick Buster
// pre-baked digits 0-9 and one texture per level numeral.  A sheet is written once
// and a new string only re-lays-out quads over it.  `atlas_writes` counts sheet
// changes, which is exactly what a GL consumer uploads on.
use text2d::*;
use graphics::*;

fn ink(cv: const graphics::Canvas, colour: integer) -> integer {
  n = 0;
  for v in cv.data { if v == colour { n = n + 1; } }
  n
}

// THE GATE.  Six hundred different strings over one digit set: the sheet is
// written ten times — once per distinct glyph — and never again.
fn test_a_changing_label_costs_no_writes() {
  at = text2d::atlas_new(1, 64);
  digits = ["0","1","2","3","4","5","6","7","8","9"];
  // warm the sheet with every glyph the run will use
  for d in digits { _ = at.layout(d, 0, 0); }
  after_warm = at.atlas_writes();
  assert(after_warm == 10, "ten distinct digits are ten writes, got {after_warm}");

  for i in 0..600 {
    a = digits[i % 10] ?? "0";
    b = digits[(i / 10 ?? 0) % 10] ?? "0";
    c = digits[(i / 100 ?? 0) % 10] ?? "0";
    _ = at.layout("{a}{b}{c}", 0, 0);
  }
  assert(at.atlas_writes() == after_warm,
         "600 relayouts wrote to the sheet {at.atlas_writes() - after_warm} times — expected 0");
  assert(at.atlas_glyph_count() == 10, "and it still holds ten glyphs");
}

// A glyph new to the sheet costs exactly one write, and only the first time.
fn test_a_new_glyph_costs_one_write() {
  at = text2d::atlas_new(1, 64);
  _ = at.layout("A", 0, 0);
  assert(at.atlas_writes() == 1, "one glyph, one write");
  _ = at.layout("A", 0, 0);
  assert(at.atlas_writes() == 1, "the same glyph again writes nothing");
  _ = at.layout("AB", 0, 0);
  assert(at.atlas_writes() == 2, "B is new, so exactly one more");
}

// Layout places one quad per character at the same pitch the direct blitter uses.
fn test_layout_places_one_quad_per_character() {
  at = text2d::atlas_new(1, 64);
  qs = at.layout("ABC", 10, 20);
  assert(len(qs) == 3, "three characters, three quads");
  assert((qs[0] ?? text2d::Quad { q_x: -1, q_y: -1, q_cell: -1 }).q_x == 10, "first at x");
  q1 = qs[1] ?? text2d::Quad { q_x: -1, q_y: -1, q_cell: -1 };
  q2 = qs[2] ?? text2d::Quad { q_x: -1, q_y: -1, q_cell: -1 };
  assert(q1.q_x == 16 and q2.q_x == 22, "pitch is 6 = glyph 5 plus a 1-pixel gap");
  assert(q1.q_y == 20, "y is the line's y");
}

// THE BASELINE.  Blitting from the sheet must give the same pixels as blitting
// direct — one bit-walk, two paths, and they may not drift.
fn test_the_sheet_reproduces_the_direct_blit() {
  s = "HELLO 42";
  direct = graphics::canvas(80, 16, 0xff000000);
  _ = text2d::write_text(direct, s, 2, 3, 0xffffffff, 1);

  at = text2d::atlas_new(1, 64);
  viaatlas = graphics::canvas(80, 16, 0xff000000);
  text2d::draw_quads(viaatlas, at, at.layout(s, 2, 3), 0xffffffff);

  same = true;
  for i in 0..len(direct.data) { if direct.data[i] != viaatlas.data[i] { same = false; } }
  assert(same, "the atlas path draws exactly what the direct path draws");
  assert(ink(direct, 0xffffffff) > 0, "and it is not two blank canvases agreeing");
}

// A full sheet refuses rather than corrupting: the quad carries -1 and draws
// nothing, so a caller sees a gap instead of another glyph's pixels.
fn test_a_full_sheet_refuses() {
  at = text2d::atlas_new(1, 2);
  _ = at.layout("AB", 0, 0);
  qs = at.layout("C", 0, 0);
  q = qs[0] ?? text2d::Quad { q_x: 0, q_y: 0, q_cell: 0 };
  assert(q.q_cell == -1, "the third glyph has no cell");
  cv = graphics::canvas(16, 16, 0xff000000);
  text2d::draw_quads(cv, at, qs, 0xffffffff);
  assert(ink(cv, 0xffffffff) == 0, "and it draws nothing rather than the wrong glyph");
}

// Scale is a property of the sheet, so a scaled sheet reproduces the scaled blit.
fn test_the_sheet_honours_its_scale() {
  at = text2d::atlas_new(2, 64);
  viaatlas = graphics::canvas(64, 32, 0xff000000);
  text2d::draw_quads(viaatlas, at, at.layout("7", 1, 1), 0xffffffff);
  direct = graphics::canvas(64, 32, 0xff000000);
  _ = text2d::write_text(direct, "7", 1, 1, 0xffffffff, 2);
  assert(ink(viaatlas, 0xffffffff) == ink(direct, 0xffffffff) and ink(direct, 0xffffffff) > 0,
         "same ink at scale 2");
}

tests/metrics.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 B1m — the metrics seam.
//
// `text2d` has no GL and no font file, so it cannot measure a real face itself.
// The consumer measures ONCE at startup through whichever backend resolved and
// hands the numbers over; this seam turns them into the answers a layout needs.
// Both halves of its shape are findings rather than preferences, and both are
// gated below: two runs are needed to know whether the resolved face is even
// fixed-pitch, and the advance has to be sub-pixel or the error accumulates.
use text2d::*;

// A 16 px DejaVu Sans Mono advances 9.6 px — the real case that motivated this.
// Ten of them measure 96.0.
WIDE_10 = 96.0;
NARROW_10 = 96.0;      // same, because the face really is fixed-pitch
LINE_H = 19;

// ── two runs, because one cannot answer the question ────────────────────────

// ⚠ TEN M's ARE TEN M's IN ANY FONT.  A single measurement gives the advance of
// an M and says NOTHING about whether the face is fixed-pitch.  Two
// same-length, opposite-width runs answer it in one comparison — and this is not
// hypothetical: asking for `DejaVuSansMono.ttf` gives a fixed-pitch face on the
// desktop and a PROPORTIONAL fallback in the browser, because the browser
// resolves the base name to a CSS family it does not know.
fn test_two_runs_decide_fixed_pitch() {
  mono = text2d::metrics_measured(WIDE_10, NARROW_10, 10, LINE_H);
  prop = text2d::metrics_measured(WIDE_10, 42.0, 10, LINE_H);
  assert(mono.is_mono(), "equal runs: the face really is fixed-pitch");
  assert(!prop.is_mono(), "different runs: it is proportional, whatever was asked for");
  // ⚠ AND THE ADVANCE COMES FROM THE WIDE RUN in both cases.  Under fixed pitch
  // the two agree; when they do not, the wider one is the safe basis for a
  // fits-in-the-box question — it over-estimates instead of overflowing.
  assert(mono.advance() == prop.advance(),
         "the narrow run informs `mono`, never the advance");
}

// ── the advance is sub-pixel, and that is not a detail ──────────────────────

// Hand-computed: 96.0 over ten characters is 9.6 px, and 9.6 x 64 = 614.4, which
// stores as 614.
fn test_the_advance_is_carried_in_sixty_fourths() {
  m = text2d::metrics_measured(WIDE_10, NARROW_10, 10, LINE_H);
  assert(m.advance64() == 614, "9.6 px in 1/64 px — got {m.advance64()}");
  assert(m.advance() == 10, "and 10 px when a caller wants whole pixels to show");
  assert(m.height() == LINE_H, "line height passes straight through");
}

// ⚠ THE REASON THE FIELD IS SUB-PIXEL, stated as a property rather than as a
// war story: a 60-character line of this face is 576 px, and NO whole-pixel
// advance can produce that number.  9 gives 540 and 10 gives 600 — the first
// under-estimates by 36 px, which is how a subject line came out 31 px narrower
// than it rasterised, and under-estimating is the direction that overflows a box
// already proved to fit.
fn test_no_whole_pixel_advance_can_express_this_face() {
  m = text2d::metrics_measured(WIDE_10, NARROW_10, 10, LINE_H);
  line = "";
  for _i in 0..60 { line += "M"; }
  assert(m.width(line) == 576, "60 chars measure 576 px — got {m.width(line)}");
  hits = 0;
  for a in 0..64 { if a * 60 == 576 { hits = hits + 1; } }
  assert(hits == 0,
         "no integer advance yields 576 over 60 characters, so a whole-pixel field cannot hold this face");
}

// ⚠ ROUNDS OUTWARD, ALWAYS.  The residual is a fraction of a pixel per
// character and the safe direction to spend it is outwards: an over-estimate
// reserves a pixel too many and the text still fits, an under-estimate spills
// out of a box that was just measured as fitting.
fn test_the_width_never_under_estimates() {
  m = text2d::metrics_measured(WIDE_10, NARROW_10, 10, LINE_H);
  s = "";
  for i in 0..40 {
    s += "M";
    // The true extent is (i+1) x 614/64, rounded UP; the seam must never answer
    // less than the exact 1/64 arithmetic.
    exact = (((i + 1) * 614 + 63) / 64) ?? 0;
    assert(m.width(s) >= exact, "{i + 1} chars: {m.width(s)} < {exact}");
    assert(m.width(s) == exact, "and it is exactly the rounded-up value");
  }
}

// A face nobody measured answers zero rather than a plausible number, so a
// caller cannot mistake "not measured" for "everything fits".
fn test_unmeasured_metrics_are_zero_not_plausible() {
  m = text2d::metrics_measured(96.0, 96.0, 0, LINE_H);
  assert(m.advance64() == 0, "a run of zero characters measures nothing");
  assert(!m.is_mono(), "and claims nothing about pitch");
  assert(m.height() == LINE_H, "the line height was not measured, it was told");
}

// ── the built-in face measures itself through the same seam ─────────────────

// ⚠ ONE SEAM, TWO SOURCES.  The 5x7 face can answer through `Metrics` too, so a
// layout written against the seam works with no font file at all — and the two
// numbers it exposes are DIFFERENT QUESTIONS, which this pins: `text_width` is
// the INK extent (what you centre by) and `Metrics.width` is the ADVANCE extent
// (what you reserve a box by).  They differ by exactly the one trailing gap the
// ink extent leaves off.
fn test_the_builtin_face_answers_through_the_seam() {
  for scale in 1..3 {
    m = text2d::metrics_builtin(scale);
    assert(m.is_mono(), "a bitmap face is fixed-pitch by construction");
    assert(m.advance() == 6 * scale, "5 px glyph + 1 px gap, scaled");
    assert(m.height() == text2d::line_height(scale), "and its line height agrees");
    for s in ["A", "ABCDE"] {
      ink = text2d::text_width(s, scale);
      assert(m.width(s) == ink + scale,
             "scale {scale} {s}: advance {m.width(s)} vs ink {ink} — one trailing gap apart");
    }
  }
}

// ── fitting ─────────────────────────────────────────────────────────────────

fn test_fits_answers_the_box_question() {
  m = text2d::metrics_builtin(1);              // 6 px per character
  assert(m.fits("ABCDE", 30), "5 x 6 = 30 fits exactly");
  assert(!m.fits("ABCDE", 29), "and one pixel less does not");
}

// ⚠ AN UNTOUCHED STRING COMES BACK UNTOUCHED.  A caller must not be able to tell
// "fitted" from "never measured" by looking for the marker, so the marker only
// ever appears when something was actually removed.
fn test_fit_text_marks_only_a_real_cut() {
  m = text2d::metrics_builtin(1);
  assert(m.fit_text("ABCDE", 30) == "ABCDE", "it fits, so it is returned whole");
  assert(m.fit_text("ABCDE", 300) == "ABCDE", "room to spare changes nothing");
  cut = m.fit_text("ABCDEFGHIJ", 30);
  assert(cut != "ABCDEFGHIJ", "it does not fit, so it was cut");
  assert(cut.ends_with(".."), "and the cut is marked: got {cut}");
  // ⚠ ".." AND NOT A SINGLE ELLIPSIS.  The ellipsis is one code point but three
  // bytes, and a marker whose own width is ambiguous is the wrong thing to
  // measure a truncation with.
  assert(!cut.contains("…"), "the marker is two full stops, not an ellipsis");
}

// A box too small for the marker itself still gets an honest answer rather than
// a string that is wider than the box it was asked to fit.
fn test_a_box_too_small_for_the_marker_still_fits() {
  m = text2d::metrics_builtin(1);              // 6 px per character
  assert(m.fit_text("ABCDEFGH", 12) == "AB", "room for two characters, no marker");
  assert(m.fit_text("ABCDEFGH", 3) == "", "no room for even one");
}

// An unmeasured face cannot truncate, so it returns the string rather than
// silently emptying every label on the screen.
fn test_an_unmeasured_face_does_not_truncate() {
  m = text2d::metrics_measured(0.0, 0.0, 0, LINE_H);
  assert(m.fit_text("ABCDEFGH", 10) == "ABCDEFGH", "nothing measured, nothing cut");
}

tests/text2d.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 B0 — text that draws with NO font file, NO native rasteriser and NO GL.
//
// That is the whole gate, and it is a consumer outcome rather than a unit test:
// `dryopea/src/hud.loft` draws its digits as RECTANGLES and `picker.loft` shipped
// with no labels, both because the only text path needed all three.  Every
// assertion below runs under plain `loft test`.
use text2d::*;
use graphics::*;

fn ink(cv: const graphics::Canvas, colour: integer) -> integer {
  n = 0;
  for v in cv.data { if v == colour { n = n + 1; } }
  n
}

// THE GATE: a known string puts a known, non-zero amount of ink on the canvas,
// with nothing loaded.
fn test_text_draws_with_nothing_loaded() {
  cv = graphics::canvas(64, 16, 0xff000000);
  w = text2d::write_text(cv, "HELLO 123", 1, 1, 0xffffffff, 1);
  assert(ink(cv, 0xffffffff) > 0, "the string put ink on the canvas");
  expect = text2d::text_width("HELLO 123", 1);
  assert(w == expect, "the drawn width matches the measured one");
}

// A blank string draws nothing and measures zero — the degenerate case proved.
fn test_an_empty_string_is_zero() {
  cv = graphics::canvas(16, 16, 0xff000000);
  _ = text2d::write_text(cv, "", 0, 0, 0xffffffff, 1);
  assert(ink(cv, 0xffffffff) == 0, "nothing drawn");
  assert(text2d::text_width("", 1) == 0, "and nothing measured");
}

// Width is exact: n glyphs of 5, with a 1-pixel gap after all but the last.
//   "AB"  -> 5 + 1 + 5      = 11
//   "ABC" -> 5 + 1 + 5 + 1 + 5 = 17
fn test_width_is_exact() {
  assert(text2d::text_width("A", 1) == 5, "one glyph is 5 wide");
  assert(text2d::text_width("AB", 1) == 11, "two glyphs and one gap");
  assert(text2d::text_width("ABC", 1) == 17, "three glyphs and two gaps");
  assert(text2d::text_width("AB", 2) == 22, "scale multiplies the whole run");
  assert(text2d::line_height(1) == 7 and text2d::line_height(3) == 21, "line height scales");
}

// Scale 2 quadruples the ink: every lit pixel becomes a 2x2 block, exactly.
fn test_scale_quadruples_the_ink() {
  a = graphics::canvas(64, 32, 0xff000000);
  _ = text2d::write_text(a, "42", 0, 0, 0xffffffff, 1);
  b = graphics::canvas(64, 32, 0xff000000);
  _ = text2d::write_text(b, "42", 0, 0, 0xffffffff, 2);
  assert(ink(b, 0xffffffff) == ink(a, 0xffffffff) * 4,
         "scale 2 is exactly 4x the ink: {ink(a, 0xffffffff)} -> {ink(b, 0xffffffff)}");
}

// ⚠ A DIGIT IS THE CASE dryopea COULD NOT DRAW.  Each of 0-9 must put ink down,
// and they must not all be the same shape — a face that drew one blob for every
// digit would pass a coverage test and be useless.
fn test_every_digit_is_drawn_and_distinct() {
  digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
  seen: vector<integer> = [];
  for d in digits {
    cv = graphics::canvas(8, 8, 0xff000000);
    _ = text2d::write_text(cv, d, 0, 0, 0xffffffff, 1);
    n = ink(cv, 0xffffffff);
    assert(n > 0, "digit {d} drew nothing");
    seen += [n];
  }
  // '1' is the thinnest and '8' among the fattest; if the face were one blob these
  // would be equal.
  assert((seen[1] ?? 0) < (seen[8] ?? 0), "'1' must carry less ink than '8'");
}

// Lowercase folds to uppercase rather than vanishing — the documented compromise.
fn test_lowercase_folds_rather_than_vanishing() {
  lower = graphics::canvas(16, 16, 0xff000000);
  _ = text2d::write_text(lower, "a", 0, 0, 0xffffffff, 1);
  upper = graphics::canvas(16, 16, 0xff000000);
  _ = text2d::write_text(upper, "A", 0, 0, 0xffffffff, 1);
  assert(ink(lower, 0xffffffff) > 0, "lowercase draws something");
  assert(ink(lower, 0xffffffff) == ink(upper, 0xffffffff), "and it is the uppercase form");
}

// An unknown character reserves its width instead of collapsing the run, so a
// string never changes length because one glyph was missing.
fn test_an_unknown_glyph_keeps_its_width() {
  cv = graphics::canvas(64, 16, 0xff000000);
  w = text2d::write_text(cv, "A~B", 0, 0, 0xffffffff, 1);
  expect2 = text2d::text_width("A~B", 1);
  assert(w == expect2, "the run is three glyphs wide either way");
}

// Centring puts the run's own width in the middle of the box.
fn test_centred_text_is_centred() {
  cv = graphics::canvas(32, 16, 0xff000000);
  _ = text2d::write_centred(cv, "A", 0, 0, 32, 16, 0xffffffff, 1);
  // 5 wide in a 32 box -> starts at 13; 7 tall in 16 -> starts at 4.
  found = false;
  for y in 0..16 {
    for x in 0..32 {
      if cv.get_pixel(x, y) == 0xffffffff and x < 13 { found = true; }
    }
  }
  assert(!found, "no ink left of the centred start column");
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `text2d` (@PLN141): each `test_*` is a running demonstration of a
// contract the signatures cannot state, cited from the function it explains with
// `// Example: @TXD-00N`.
use graphics::*;
use text2d::*;

// @TXD-001 — what is measured is what is drawn.  `text_width` is the INK extent: n glyphs of 5
// with a gap after all but the last, so "HI" is 11 at scale 1 and 22 at scale 2, and it is
// exactly what `write_text` answers — an empty string included (0), and a scale below 1, which
// draws at 1 and measures at 1.  `Metrics.width` is the ADVANCE extent, what a box is reserved
// by: one trailing gap more.
fn test_measured_is_drawn() {
  cv = canvas(64, 32, 0);
  assert(text_width("HI", 1) == 11 && text_width("HI", 2) == 22, "5 + 1 + 5, then doubled");
  assert(write_text(cv, "HI", 0, 0, 0xFFFFFFFF, 2) == text_width("HI", 2), "drawn = measured");
  assert(write_text(cv, "", 0, 0, 0xFFFFFFFF, 1) == 0, "an empty string draws 0 wide");
  assert(write_text(cv, "HI", 0, 0, 0xFFFFFFFF, 0) == text_width("HI", 0), "scale 0 draws and measures at 1");
  assert(metrics_builtin(1).width("HI") == text_width("HI", 1) + 1, "the advance extent has the trailing gap");
}

// @TXD-002 — text is cut by CHARACTERS.  `len` counts characters and `s[a..b]` slices bytes, so
// a count applied as a byte range keeps fewer characters than it measured, and only in text
// that is not ASCII: "héllo"[0..5] is "héll".  `take_chars` takes characters, and `fit_text`
// cuts with it, leaving room for its ".." marker.
fn test_text_is_cut_by_characters() {
  s = "héllo";
  assert(len(s) == 5 && s[0..5] == "héll", "a byte range of 5 is four characters here");
  assert(take_chars(s, 5) == "héllo" && take_chars(s, 2) == "hé", "take_chars counts characters");
  m = metrics_builtin(1);
  assert(m.fit_text("ÉCOLE PRIMAIRE", 30) == "ÉCO..", "five cells: three characters and the marker");
  assert(m.fit_text("ÉCOLE", 30) == "ÉCOLE", "a string that fits comes back untouched");
}

// @TXD-003 — a changing label costs no sheet write.  The atlas rasterises a glyph the first
// time it meets it and never again, so `atlas_writes` — what a GL consumer uploads on — stops
// at the number of distinct glyphs however much text goes through.  A full sheet REFUSES: the
// quad carries cell -1 and draws nothing, rather than another glyph's pixels.
fn test_a_changing_label_costs_no_write() {
  at = atlas_new(1, 16);
  for i in 0..600 { _ = at.layout("{i % 10}", 0, 0); }
  assert(at.atlas_writes() == 10 && at.atlas_glyph_count() == 10, "ten digits, ten writes");
  for i in 0..600 { _ = at.layout("{i % 10}", 0, 0); }
  assert(at.atlas_writes() == 10, "and none after");
  full = atlas_new(1, 2);
  q = full.layout("ABC", 0, 0);
  assert(q[0].q_cell == 0 && q[1].q_cell == 1 && q[2].q_cell == -1, "the third glyph has no room");
}

tests/wrap.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 B2 — wrapping and alignment.
//
// ⚠ THE BREAK TABLE IS PER TARGET, so there is no shared one to assert.  Native
// measures a real TTF and the browser measures whatever family resolved, so the
// same string breaks in different places.  What IS cross-target is
// SELF-CONSISTENCY: the text a target draws fits the box that same target
// measured.  The hand-computed table below therefore uses the BUILT-IN face —
// the one target whose measurement is knowable here — and the property gate
// runs the same invariant over several different metrics.
use text2d::*;

fn joined(v: const vector<text>) -> text {
  out = "";
  for s in v { out += "[" + s + "]"; }
  out
}

// ── the hand-computed table, on the one face whose metrics are knowable ─────

// `metrics_builtin(1)` advances 6 px per character, so a width in pixels is a
// character count times six and every expectation below is arithmetic.
fn test_the_break_table_is_exact_on_the_builtin_face() {
  m = text2d::metrics_builtin(1);
  s = "aaa bbb ccc ddd";
  assert(joined(m.wrap(s, 18)) == "[aaa][bbb][ccc][ddd]",
         "3 characters wide: {joined(m.wrap(s, 18))}");
  assert(joined(m.wrap(s, 42)) == "[aaa bbb][ccc ddd]",
         "7 characters wide: {joined(m.wrap(s, 42))}");
  assert(joined(m.wrap(s, 66)) == "[aaa bbb ccc][ddd]",
         "11 characters wide: {joined(m.wrap(s, 66))}");
  assert(joined(m.wrap(s, 90)) == "[aaa bbb ccc ddd]",
         "the whole string fits: {joined(m.wrap(s, 90))}");
}

// A word longer than the box is broken by CHARACTERS rather than left to
// overflow — a box proved to fit must actually contain what is drawn in it.
fn test_a_word_wider_than_the_box_is_broken() {
  m = text2d::metrics_builtin(1);
  assert(joined(m.wrap("aaaaaaaaa", 30)) == "[aaaaa][aaaa]",
         "9 characters at 5 per line: {joined(m.wrap(\"aaaaaaaaa\", 30))}");
}

// ── the character-vs-byte trap ──────────────────────────────────────────────

// ⚠ THE LIVE TRAP, AND IT IS NOT THEORETICAL.  `len(text)` counts CHARACTERS
// while `s[a..b]` slices BYTES: `"héllo"[0..5]` is `"héll"`, four characters,
// not five.  A wrapper that breaks with a byte slice therefore fits fewer
// characters than it measured — silently, and only for text that is not ASCII.
//
// Nine o-umlauts are nine characters and eighteen bytes.  At five characters per
// line the answer is 5 then 4.  A byte-sliced break gives 3 on the first line,
// which is why this cell is here and why it uses a word that must be broken.
fn test_multi_byte_text_breaks_by_characters_not_bytes() {
  m = text2d::metrics_builtin(1);
  s = "ööööööööö";
  assert(len(s) == 9 and size(s) == 18, "nine characters, eighteen bytes");
  got = m.wrap(s, 30);
  assert(joined(got) == "[ööööö][öööö]", "5 then 4 characters: {joined(got)}");
  assert(len(got[0] ?? "") == 5, "the first line holds five CHARACTERS");
}

// Nothing is dropped and nothing is invented, whatever the text is made of.
fn test_wrapping_preserves_every_character() {
  m = text2d::metrics_builtin(1);
  for s in ["aaa bbb ccc ddd", "héllo wörld", "ööööööööö", "a", ""] {
    total = 0;
    for line in m.wrap(s, 30) { total += len(line); }
    spaces = 0;
    for c in s { if c == ' ' { spaces = spaces + 1; } }
    // A break consumes the space it broke at, so the characters that survive are
    // everything but the separators that became line ends.
    assert(total <= len(s) and total >= len(s) - spaces,
           "{s}: kept {total} of {len(s)} characters");
  }
}

// ── the cross-target invariant ──────────────────────────────────────────────

// ⚠ SELF-CONSISTENCY IS THE GATE THAT TRAVELS.  A break table cannot: the same
// string breaks in different places on every target.  This one holds on all of
// them — every line a target produces fits the box that target measured.  It is
// run over a fixed-pitch face, a fractional-advance face (9.6 px, where the
// rounding lives) and a proportional one, because a rule proved on one metric is
// a rule proved on one metric.
fn test_every_line_fits_the_box_it_was_wrapped_to() {
  ms = [text2d::metrics_builtin(1),
        text2d::metrics_builtin(3),
        text2d::metrics_measured(96.0, 96.0, 10, 19),    // 9.6 px, fractional
        text2d::metrics_measured(120.0, 61.0, 10, 19)];  // proportional
  for mi in 0..len(ms) {
    m = ms[mi];
    for s in ["aaa bbb ccc ddd", "héllo wörld", "supercalifragilistic", "a b c"] {
      for px in [20, 30, 48, 90, 200] {
        for line in m.wrap(s, px) {
          // The one exception is a box too narrow for a single character: a line
          // always takes at least one, because dropping text or looping forever
          // are both worse answers than one that overflows and says so.
          if len(line) > 1 {
            assert(m.fits(line, px),
                   "metric {mi}, box {px}: '{line}' measures {m.width(line)}");
          }
        }
      }
    }
  }
}

// ⚠ A LINE ALWAYS TAKES AT LEAST ONE CHARACTER, so wrapping terminates even in a
// box narrower than the narrowest glyph.  The alternatives are an infinite loop
// or silently dropping the text, and both are worse than a line that overflows.
fn test_a_box_too_narrow_for_one_character_still_terminates() {
  m = text2d::metrics_builtin(1);              // 6 px per character
  got = m.wrap("abc", 3);
  assert(joined(got) == "[a][b][c]", "one character per line: {joined(got)}");
}

// ── explicit breaks ─────────────────────────────────────────────────────────

// A newline is a break the author asked for, so it is honoured even where the
// line would have fitted.  A wrapper that swallowed them would silently reflow
// every deliberately laid-out block.
fn test_an_explicit_newline_breaks_a_line_that_would_fit() {
  m = text2d::metrics_builtin(1);
  assert(joined(m.wrap("ab\ncd", 600)) == "[ab][cd]",
         "the box is huge, the break is the author's: {joined(m.wrap(\"ab\\ncd\", 600))}");
}

// ── the degenerate cases ────────────────────────────────────────────────────

fn test_an_empty_string_is_one_empty_line() {
  m = text2d::metrics_builtin(1);
  got = m.wrap("", 60);
  assert(len(got) == 1 and (got[0] ?? "x") == "",
         "an empty document still has a line to put a caret on");
}

// ── alignment ───────────────────────────────────────────────────────────────

// Hand-computed in a box at x = 100, 60 px wide, with "abc" measuring 18 px:
// left 100, centred 100 + (60-18)/2 = 121, right 100 + 60 - 18 = 142.
fn test_the_three_alignments_are_hand_computed() {
  m = text2d::metrics_builtin(1);
  assert(m.align_x("abc", 100, 60, text2d::ALIGN_LEFT) == 100, "left");
  assert(m.align_x("abc", 100, 60, text2d::ALIGN_CENTRE) == 121, "centred");
  assert(m.align_x("abc", 100, 60, text2d::ALIGN_RIGHT) == 142, "right");
}

// A line wider than its box starts at the box, never outside it — the text
// overflows to the right, where it can be clipped, rather than off the left edge
// where the beginning of it is lost.
fn test_an_overlong_line_is_not_pushed_off_the_left() {
  m = text2d::metrics_builtin(1);
  for a in [text2d::ALIGN_LEFT, text2d::ALIGN_CENTRE, text2d::ALIGN_RIGHT] {
    assert(m.align_x("aaaaaaaaaa", 100, 12, a) == 100,
           "alignment {a} keeps the start of the line visible");
  }
}

// ── the shipped truncation learns the same lesson ───────────────────────────

// ⚠ `fit_text` SHIPPED IN B1m CUTTING A BYTE RANGE WITH A CHARACTER COUNT.  It
// under-filled rather than corrupting — loft snaps a byte cut outward to a
// character boundary — so it was safe and wrong: a label that fits came back
// shorter than it needed to be, and only ever in non-ASCII text.
fn test_fit_text_cuts_by_characters() {
  m = text2d::metrics_builtin(1);              // 6 px per character
  // 8 characters of room; two go to the marker, so six survive.
  assert(m.fit_text("öööööööööö", 48) == "öööööö..",
         "six characters then the marker: {m.fit_text(\"öööööööööö\", 48)}");
  assert(m.fit_text("aaaaaaaaaa", 48) == "aaaaaa..", "and ASCII is unchanged");
}