← 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.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
GLYPH_W const | 15 | — | 47 48 71 96 150 173 … (10 total) |
GLYPH_H const | 16 | — | 31 45 75 150 174 178 … (7 total) |
face_codes fn | 19 | — | 25 |
face_rows fn | 29 | — | 44 |
blit_glyph fn | 40 | — | 95 179 |
text_width fn | 67 | TXD-001 | 105 109 25 34 41 42 … (13 total) |
line_height fn | 75 | — | 106 273 107 45 |
write_text fn | 90 | — | 107 70 101 23 32 51 … (14 total) |
write_centred fn | 103 | — | 98 |
Atlas struct | 120 | — | 139 149 158 164 167 188 … (7 total) |
Quad struct | 130 | — | 188 189 193 200 58 59 … (8 total) |
atlas_new fn | 139 | — | 24 44 55 72 85 97 … (8 total) |
atlas_glyph_count fn | 158 | — | 39 44 |
atlas_writes fn | 164 | TXD-003 | 28 37 38 46 48 50 … (8 total) |
atlas_cell fn | 167 | — | 192 |
layout fn | 188 | — | 27 35 45 47 49 56 … (13 total) |
draw_quads fn | 200 | — | 74 91 99 |
Metrics struct | 233 | — | 260 262 264 271 272 278 … (17 total) |
metrics_measured fn | 259 | — | 29 30 45 58 73 88 … (9 total) |
metrics_builtin fn | 271 | — | 104 119 128 143 21 32 … (18 total) |
advance64 fn | 278 | — | 46 89 |
advance fn | 284 | — | 243 332 36 37 47 65 … (9 total) |
height fn | 287 | — | 174 244 48 91 107 45 |
is_mono fn | 291 | — | 31 32 90 105 |
width fn | 304 | — | 173 312 331 432 440 474 … (14 total) |
fits fn | 311 | — | 120 121 129 34 35 102 |
fit_text fn | 329 | — | 129 130 131 144 145 152 … (11 total) |
ALIGN_LEFT const | 355 | — | 146 156 |
ALIGN_CENTRE const | 356 | — | 476 147 156 |
ALIGN_RIGHT const | 357 | — | 477 148 156 |
take_chars fn | 362 | TXD-002 | 340 342 442 31 |
wrap fn | 397 | — | 28 29 30 31 32 33 … (17 total) |
align_x fn | 472 | — | 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");
}