← lavition_ui · API reference · version 0.1.1
12 file(s), 2346 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 |
|---|---|---|---|
Metrics struct | 21 | — | 43 61 63 65 81 85 … (17 total) |
metrics_advance fn | 43 | — | 126 27 46 |
metrics_measured fn | 60 | — | 15 14 16 25 37 44 … (11 total) |
text_width fn | 81 | — | 87 123 224 232 213 53 … (17 total) |
text_height fn | 85 | — | 222 296 297 299 300 |
fits fn | 87 | — | 94 120 122 166 107 |
fit_text fn | 121 | — | 150 157 182 203 207 156 … (18 total) |
hotkey_room fn | 44 | — | 157 212 224 226 |
SpecButton struct | 50 | — | 62 71 72 |
PanelSpec struct | 61 | — | 141 29 184 19 20 175 … (15 total) |
spec_button fn | 71 | — | 31 32 22 23 147 148 … (16 total) |
Panel struct | 76 | — | 142 190 242 250 63 130 … (12 total) |
subject_rect fn | 94 | — | 188 62 71 |
panel_rect fn | 101 | — | 192 52 |
toolbar_button_rect fn | 108 | — | 146 54 72 78 79 80 … (7 total) |
list_rect fn | 117 | — | 163 95 96 104 |
status_rect fn | 130 | — | 187 72 110 114 |
panel_build fn | 141 | — | 39 188 120 125 133 152 … (21 total) |
UiHit enum | 220 | — | 250 18 23 |
list_row_rect fn | 242 | — | 130 175 |
panel_hit_test fn | 250 | — | 45 55 73 83 97 106 … (11 total) |
DrawRect struct | 21 | — | 63 64 67 68 69 74 … (16 total) |
Theme struct | 28 | — | 42 43 63 130 193 214 |
theme_default fn | 42 | — | 29 30 38 53 65 83 … (28 total) |
panel_draw_list fn | 63 | — | 29 39 54 66 83 94 … (11 total) |
DrawText struct | 118 | — | 130 131 138 140 156 165 … (20 total) |
panel_text_list fn | 130 | — | 145 162 186 204 213 225 … (12 total) |
verbbar_height fn | 76 | — | 137 35 36 |
SpecVerb struct | 84 | — | 91 92 95 96 133 23 … (8 total) |
spec_verb fn | 91 | — | 27 120 122 192 232 263 … (9 total) |
spec_verb_on fn | 95 | — | 194 |
VerbSlot struct | 100 | — | 110 147 153 186 88 89 … (9 total) |
VerbBar struct | 108 | — | 134 165 174 184 193 214 |
verbbar_build fn | 133 | — | 34 52 53 69 85 105 … (20 total) |
verbbar_hit fn | 174 | — | 148 149 150 152 165 166 … (8 total) |
verbbar_verb fn | 184 | — | 150 152 169 170 |
verbbar_draw_list fn | 193 | — | 177 192 194 |
verbbar_text_list fn | 214 | — | 206 234 246 291 |
UiRect struct | 10 | — | 77 94 101 108 117 130 … (16 total) |
rect fn | 17 | — | 95 105 110 122 131 243 … (13 total) |
rect_contains fn | 21 | — | 256 260 177 140 37 42 … (10 total) |
Button struct | 27 | — | 78 143 147 126 127 164 … (7 total) |
KIND_UNSET const | 51 | — | 61 72 168 25 26 |
Entry struct | 53 | — | 64 164 181 60 61 64 … (11 total) |
entry fn | 60 | — | 35 26 166 168 151 21 … (17 total) |
entry_of fn | 64 | — | 149 150 12 13 |
entry_blocked fn | 71 | — | 343 368 26 27 28 |
entry_blocked_of fn | 75 | — | 72 151 16 |
ListBox struct | 82 | — | 79 194 |
StatusStrip struct | 91 | — | 80 81 201 205 |
src/font.loft
// Text metrics for the panel — MEASURED, never assumed.
//
// This file used to say `text_width(s) = len(s) * 8` and expose a GLYPH_W of 8,
// waiting for "the loft_gl text bridge in Step 9". The bridge landed on
// 2026-08-03, and `probe/b1` measured what it actually gives:
//
// desktop advance 19.27 MMMMMMMMMM 192.66 = iiiiiiiiii 192.66
// browser advance 26.66 MMMMMMMMMM 266.56 / iiiiiiiiii 71.09
//
// So `8` was wrong on both targets, and the two targets do not even agree with
// each other. A panel laid out on a made-up advance mis-lays every row, and
// nothing reports it: the text still draws, it is just in the wrong place.
//
// ⚠ THE LIBRARY STAYS PURE. These tests run under `loft test` with no window
// and no GL context, so nothing here may call `gl_measure_text`. The consumer
// measures once at startup and hands the numbers over; this file does the
// arithmetic. That is the whole seam.
// What the consumer measured. `m_mono` is a FACT about the font that was
// resolved, not a request that was made — see `metrics_measured`.
pub struct Metrics {
// ⚠ THE ADVANCE IS IN 1/64 PIXEL, and a whole-pixel one is what B1.6 caught.
//
// It was `m_advance: integer`. DejaVu Sans Mono at 16px advances 9.6px, which
// truncates to 9 — and the error is per CHARACTER, so it accumulates: the
// editor's own subject line measured **31px narrower** than the bridge
// rasterises it. Under-estimating is the dangerous direction, because
// `fit_text` 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 makes the residual under a
// pixel over any line a panel can hold.
m_adv64: integer, // advance in 1/64 px
m_line_h: integer, // line height in pixels
m_mono: boolean, // is it actually fixed-pitch?
}
// The advance in whole pixels, for a caller that wants to show it.
//
// ⚠ NOT A SECOND FIELD. One quantity stored twice is how the hotkey column got
// its collision — two numbers describing one distance drift apart the moment one
// is edited. This is derived.
pub fn metrics_advance(m: Metrics) -> integer { (m.m_adv64 + 32) / 64 ?? 0 }
// Build metrics from two runs the consumer measured through the bridge: `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 tells you the
// advance of an M and nothing about whether the font is fixed-pitch. Two
// same-length, opposite-width strings answer it in one comparison — and getting
// this wrong is not hypothetical: asking for `DejaVuSansMono.ttf` gives a
// fixed-pitch font on the desktop and a PROPORTIONAL fallback in the browser,
// because the browser resolves the path's base name to a CSS family it does not
// know (`probe/b1`).
//
// `m_advance` is taken from the wide run: under fixed pitch the two agree, and
// when they do not, the wider one is the safer 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,
}
}
// Pixel width of a string at these metrics.
//
// Exact under fixed pitch, an OVER-estimate otherwise (see `metrics_measured`),
// which is the direction that keeps a label inside its box rather than spilling
// out of it.
// ⚠ 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 fits, an under-estimate overflows a box that was measured as
// fitting. Same reason `metrics_measured` takes the WIDE run.
pub fn text_width(s: text, m: Metrics) -> integer {
((len(s) * m.m_adv64 + 63) / 64) ?? 0
}
pub fn text_height(m: Metrics) -> integer { m.m_line_h }
pub fn fits(s: text, px: integer, m: Metrics) -> boolean { text_width(s, m) <= px }
// The longest prefix of `s` that fits in `px`, with ".." marking the cut.
//
// Returns `s` untouched when it already fits, so a caller cannot tell "fitted"
// from "not measured" by looking for the marker — which is why the marker is
// only ever added when something was actually removed.
//
// ".." rather than a single "…": the ellipsis is one code point but several
// bytes, and `text_width` counts characters. A marker whose own width is
// ambiguous is the wrong thing to measure a truncation with.
// The byte offset just past the `n`th character of `s`, or its end if it has
// fewer than `n`.
//
// ⚠ THIS EXISTS BECAUSE THE TWO UNITS DO NOT MEET. `len` counts characters and a
// slice bound is a byte offset, and loft's stdlib has nothing between them — so a
// cut computed in characters, which is what an advance gives you, has to be
// walked into an offset before it can be used. `c#next` is the byte just past the
// current character, which is exactly the cut point.
//
// ⚠ The failure it prevents is SILENT: loft snaps a byte cut outward to a
// character boundary, so a character count spent as bytes never corrupts the
// text — it just stops early, and the label comes back shorter than the box it
// was measured against. Every ASCII fixture agrees with the bug.
fn byte_after_chars(s: text, n: integer) -> integer {
if n <= 0 { return 0; }
bac_seen = 0;
for bac_c in s {
bac_seen += 1;
if bac_seen >= n { return bac_c#next; }
}
size(s)
}
pub fn fit_text(s: text, px: integer, m: Metrics) -> text {
if m.m_adv64 <= 0 { return s; }
if text_width(s, m) <= px { 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.
adv = metrics_advance(m);
if adv <= 0 { return s; }
keep = ((px / adv) ?? 0) - 2;
if keep <= 0 {
room = (px / adv) ?? 0;
if room <= 0 { return ""; }
return s[0..byte_after_chars(s, room)];
}
s[0..byte_after_chars(s, keep)] + ".."
}
src/lavition_ui.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// lavition_ui — the editor's side panel: layout, hit-test and text metrics.
//
// lavition's, not Moros's. It holds no world, no lattice and no tool: a
// consumer hands it a `PanelSpec` of labels and indices, and gets back
// rectangles and hits. See doc/claude/EDITOR_UI.md.
// ⚠ `self::`, ON EVERY ONE — loft#976, and this package is the reason it matters.
// A module's BASENAME is global across a consumer's whole dependency graph, so a
// bare `use render;` here claims the name `render` in every graph this library is
// pulled into — and the registry's `graphics` has shipped a `src/render.loft` since
// 0.1.0. Two bare claims and the CONSUMER's `use` line order decides which library
// loses its module, reported as an unknown function in a file its author never
// touched. `src/editor_client.loft` already links both.
//
// ⚠ NOTHING IS RED TODAY and that is not the test. `graphics` does not claim its
// `render` bare, so `tools/basenames.sh` passes — the rule it enforces is *two bare
// claims*, not *two files*. What changes on publication is that this package becomes
// a stranger in graphs nobody here can see, where the other claim is somebody else's
// to make. `panel`, `font`, `widgets` and `render` are four of the most ordinary
// names a UI library could hold.
pub use self::widgets::*;
pub use self::font::*;
pub use self::panel::*;
pub use self::render::*;
pub use self::verbbar::*;
src/panel.loft
// The editor's side panel: layout, the spec that fills it, and hit-testing.
//
// A fixed-width strip on the left of the window. Toolbar at the top, scrollable
// list below, a status bar across the bottom of the window.
//
// ⚠ THIS FILE KNOWS NOTHING ABOUT WHAT IT IS SHOWING. It was `moros_ui`'s and
// took a `ToolState`, which is why it dragged `moros_sim`'s player physics and
// collision into a layout library and made the panel unusable by the browser
// client — the one program that needs one. What crosses the seam now is DATA:
// labels, indices and a status string the consumer already has.
//
// See doc/claude/EDITOR_UI.md §U2.
use self::widgets::*;
use self::font::*;
// ── Layout constants ─────────────────────────────────────────────
pub PANEL_WIDTH = 240;
pub TOOLBAR_ROW_HEIGHT = 32;
pub TOOLBAR_GAP = 4;
pub TOOLBAR_TOP = 8;
pub SEPARATOR_HEIGHT = 2;
pub LIST_ITEM_HEIGHT = 20;
pub STATUS_HEIGHT = 24;
// ⚠ THE SUBJECT LINE GETS ITS OWN BAR, ACROSS THE TOP, and the width is what
// decides that. CATALOGUE §C1 wants it top-left and always visible, and its own
// example — `world hollow-hill · AUTO · level ON · run armed · roof hidden ·
// 30 Hz` — is 69 characters, about 620px. It was never going to live in a 240px
// column; putting it there would truncate the answer to "what am I working on"
// down to the first two words.
pub SUBJECT_HEIGHT = 24;
// Room a label does not get: the padding inside its box, and the column the
// hotkey glyph sits in.
pub LABEL_PAD = 12;
pub HOTKEY_COL = 20;
// Where the hotkey starts inside its button, and therefore how much room it has:
// from HOTKEY_X to the label's start at HOTKEY_COL, less a gap so the two never
// touch. ⚠ DERIVED, not a second magic number — the first attempt fitted the
// hotkey to `HOTKEY_COL - 4` while drawing it at `+6`, which left it 2px longer
// than the gap and put it straight back under the label it was being moved out
// of. Two numbers describing one distance is how that happens.
pub HOTKEY_X = 6;
pub HOTKEY_GAP = 2;
pub fn hotkey_room() -> integer { HOTKEY_COL - HOTKEY_X - HOTKEY_GAP }
// ── What a consumer hands over ───────────────────────────────────
//
// One entry per toolbar button. `sb_hotkey` is shown, not bound — binding keys
// is the consumer's, because only it knows what a key should do.
pub struct SpecButton {
sb_label: text,
sb_hotkey: text,
}
// The whole contents of a panel, as plain data.
//
// ⚠ INDICES, NOT OBJECTS. `ps_selected_button` is an index into `ps_buttons`,
// not a `ToolKind`; `ps_selected_item` an index into `ps_items`. That is what
// lets Moros, crawler and a browser client each map their own state to the same
// panel — and it is also what keeps a click from meaning anything here (§U3).
pub struct PanelSpec {
ps_buttons: vector<SpecButton>,
ps_selected_button: integer,
ps_items: vector<Entry>,
ps_selected_item: integer,
ps_status: text,
// What you are working on and what is switched on — §C1's one line.
ps_subject: text,
}
pub fn spec_button(label: text, hotkey: text) -> SpecButton {
SpecButton { sb_label: label, sb_hotkey: hotkey }
}
// ── Panel struct ─────────────────────────────────────────────────
pub struct Panel {
p_rect: UiRect,
p_toolbar: vector<Button>,
p_list: ListBox,
p_status: StatusStrip,
p_subject: StatusStrip,
// ⚠ THE METRICS IT WAS BUILT WITH, carried rather than passed again.
// `panel_build` FITS every label to its box with these; `panel_text_list`
// PLACES the same labels. If the two could be handed different metrics, a
// panel could be fitted at one advance and drawn at another — text that
// overflows the box it was just proved to fit in, and nothing to catch it.
// Carrying them makes that unrepresentable rather than merely unlikely.
p_metrics: Metrics,
}
// ── Layout helpers ───────────────────────────────────────────────
// The subject bar: full width, at the very top, never covered by anything.
pub fn subject_rect(window_w: integer) -> UiRect {
rect(0, 0, window_w, SUBJECT_HEIGHT)
}
// ⚠ THE STRIP STARTS BELOW THE SUBJECT BAR rather than under it. An always-visible
// line that something else is drawn over is not always visible, and "never
// hidden" is the one property §C1 asks of it by name.
pub fn panel_rect(window_w: integer, window_h: integer) -> UiRect {
// window_w is present so callers do not have to special-case the single fixed
// strip; if the panel grows a right-aligned variant, the dimension is here.
_ = window_w;
rect(0, SUBJECT_HEIGHT, PANEL_WIDTH, window_h - SUBJECT_HEIGHT)
}
pub fn toolbar_button_rect(idx: integer) -> UiRect {
tbr_y = SUBJECT_HEIGHT + TOOLBAR_TOP + idx * (TOOLBAR_ROW_HEIGHT + TOOLBAR_GAP);
rect(8, tbr_y, PANEL_WIDTH - 16, TOOLBAR_ROW_HEIGHT)
}
// ⚠ THE BUTTON COUNT IS A PARAMETER, not a constant. It was `TOOLBAR_BUTTONS =
// 6`, which is Moros's tool count — a piece of one consumer's content baked into
// the geometry of every consumer's panel. The list starts below however many
// buttons it was given.
pub fn list_rect(window_h: integer, n_buttons: integer) -> UiRect {
lr_top = SUBJECT_HEIGHT + TOOLBAR_TOP
+ n_buttons * (TOOLBAR_ROW_HEIGHT + TOOLBAR_GAP)
+ SEPARATOR_HEIGHT + 8;
lr_bottom = window_h - STATUS_HEIGHT - 4;
rect(8, lr_top, PANEL_WIDTH - 16, lr_bottom - lr_top)
}
// ⚠ THE STATUS BAR SPANS THE WINDOW, and it did not until the metrics were made
// load-bearing. It was `PANEL_WIDTH` — 240 px — while the text it is handed runs
// to 80-odd characters, about 648 px. It overflowed its own box by 2.7x from the
// day it was written, and nothing reported it because nothing measured: the
// renderer was never built, so the text never met its rectangle.
pub fn status_rect(window_w: integer, window_h: integer) -> UiRect {
rect(0, window_h - STATUS_HEIGHT, window_w, STATUS_HEIGHT)
}
// ── Panel build ──────────────────────────────────────────────────
// ⚠ EVERY LABEL IS FITTED TO ITS BOX HERE, not left for the renderer to overflow.
// How many characters fit is a question about the FONT, which this library
// cannot know — so the consumer measures once and hands over `Metrics`, and the
// fit becomes a pure testable fact about the panel rather than something a
// renderer discovers by drawing past the edge.
pub fn panel_build(spec: PanelSpec, window_w: integer, window_h: integer,
metrics: Metrics) -> Panel {
pb_buttons: vector<Button> = [];
pb_i = 0;
for pb_sb in spec.ps_buttons {
pb_br = toolbar_button_rect(pb_i);
pb_buttons += [Button {
btn_id: pb_i,
btn_rect: pb_br,
btn_label: fit_text(pb_sb.sb_label, pb_br.r_w - LABEL_PAD - HOTKEY_COL, metrics),
// ⚠ THE HOTKEY IS FITTED TOO, and it was not until a picture showed why.
// The label was measured against its box and the hotkey placed beside it
// UNMEASURED, so a one-character hint fitted and `drag` ran straight under
// the label: `d` and `Look` overlapping in the first button, legible as
// neither. A string placed in a box nobody measured is the same class
// `B1.2` was about, one column over.
btn_hotkey_label: fit_text(pb_sb.sb_hotkey, hotkey_room(), metrics),
btn_selected: pb_i == spec.ps_selected_button,
}];
pb_i = pb_i + 1;
}
pb_lr = list_rect(window_h, len(spec.ps_buttons));
pb_items: vector<Entry> = [];
for pb_it in spec.ps_items {
// ⚠ THE REASON IS FITTED WITH THE NAME, as one row, because that is what the
// author reads. At 224px a long reason truncates — `fit_text` marks the cut —
// and the FULL reason stays on the entry for a consumer with somewhere
// roomier to put it. A row that showed only the name would grey a thing out
// and not say why, which is the blank no again.
pb_txt = pb_it.en_label;
if !pb_it.en_available { pb_txt = "{pb_it.en_label} — {pb_it.en_reason}"; }
// ⚠ THE KIND IS CARRIED, AND IT WAS NOT. `B5.1` put `en_kind` on `Entry` so
// ONE list could hold two families, and this rebuild left it out — so every
// row in `lb_items` came out `KIND_UNSET` and a renderer reading the built
// panel could not tell a part from a material. Nothing noticed, because no
// consumer read the kind until `B5.2` drew an image per row: the field
// reached the struct, the wire and the parser, and stopped one line short of
// its only reader. The LABEL is fitted here and the kind is not — a kind is
// a handle, not something shown, so there is no box to fit it to.
pb_items += [Entry { en_kind: pb_it.en_kind,
en_label: fit_text(pb_txt, pb_lr.r_w - LABEL_PAD, metrics),
en_available: pb_it.en_available,
en_reason: pb_it.en_reason }];
}
pb_sr = status_rect(window_w, window_h);
pb_qr = subject_rect(window_w);
Panel {
p_metrics: metrics,
p_rect: panel_rect(window_w, window_h),
p_toolbar: pb_buttons,
p_list: ListBox {
lb_rect: pb_lr,
lb_items: pb_items,
lb_selected: spec.ps_selected_item,
lb_scroll: 0,
lb_item_height: LIST_ITEM_HEIGHT,
},
p_status: StatusStrip {
ss_rect: pb_sr,
ss_text: fit_text(spec.ps_status, pb_sr.r_w - LABEL_PAD, metrics),
},
p_subject: StatusStrip {
ss_rect: pb_qr,
ss_text: fit_text(spec.ps_subject, pb_qr.r_w - LABEL_PAD, metrics),
},
}
}
// ── Hit-test ─────────────────────────────────────────────────────
//
// ⚠ A CLICK RETURNS A HIT; IT DOES NOT MEAN ANYTHING. There used to be a
// `route_click` beside this that also mutated the caller's tool state. Only the
// consumer knows what a click on item 3 means — and for a client talking to a
// server the answer is usually "send a message and wait to be told", not "set a
// local field" (CATALOGUE.md §C1). With the mutation gone, `route_click` was
// this function under another name, so it is not here.
pub enum UiHit {
UhWorld,
UhToolButton { tb_id: integer },
UhListItem { li_idx: integer },
UhNone,
}
// Where row `idx` sits on screen — the inverse of `panel_hit_test`'s list arm.
//
// ⚠ IT EXISTS SO A CONSUMER NEED NOT RE-DERIVE THE LAYOUT. Drawing a row,
// clicking one, and driving one from a test all want the same rectangle, and the
// only other way to have it is to write `lb_rect.r_y + idx * lb_item_height`
// out again at each site — a second copy of the layout, which stays green right
// up until the layout moves. `verbbar` has answered this since it shipped, with
// a `vt_rect` on every slot; the list did not, and the list is the half a
// catalogue is clicked in.
//
// ⚠ THE RECTANGLE COMES BACK EVEN WHEN THE ROW IS SCROLLED OUT OF SIGHT.
// Clamping it here would answer a different question — *is this row visible* —
// and a caller that wants that one asks `rect_contains(p.p_list.lb_rect, …)` of
// the centre. An index with no row at all is the one case with no answer, and it
// gets an empty rectangle rather than a plausible one.
pub fn list_row_rect(p: Panel, idx: integer) -> UiRect {
if idx < 0 || idx >= len(p.p_list.lb_items) { return rect(0, 0, 0, 0); }
rect(p.p_list.lb_rect.r_x,
p.p_list.lb_rect.r_y + idx * p.p_list.lb_item_height - p.p_list.lb_scroll,
p.p_list.lb_rect.r_w,
p.p_list.lb_item_height)
}
pub fn panel_hit_test(p: Panel, mx: integer, my: integer) -> UiHit {
// Outside the panel's x-range → the world behind it.
if mx >= p.p_rect.r_x + p.p_rect.r_w {
return UhWorld;
}
for pht_b in p.p_toolbar {
if rect_contains(pht_b.btn_rect, mx, my) {
return UhToolButton { tb_id: pht_b.btn_id };
}
}
if rect_contains(p.p_list.lb_rect, mx, my) {
pht_local_y = my - p.p_list.lb_rect.r_y + p.p_list.lb_scroll;
// A zero item height is a mis-built panel, not a hit: -1 falls outside the
// range check below, so it reports "no item" rather than a null index.
pht_idx = (pht_local_y / p.p_list.lb_item_height) ?? -1;
if pht_idx >= 0 && pht_idx < len(p.p_list.lb_items) {
return UhListItem { li_idx: pht_idx };
}
}
UhNone
}
src/render.loft
// What the panel looks like — as a LIST OF RECTANGLES, not as GL calls.
//
// ⚠ WHY A LIST AND NOT A `panel_render(painter, …)`. Taking a `Painter2D` would
// pull `graphics` into this package, and then its tests would need a window: the
// layout suite runs under `loft test` with no GL context at all, which is the
// property that made the whole re-homing worth doing.
//
// It also moves a real claim out of the picture and into a test. "The selected
// button is drawn differently from the others" is a fact about the DECISION, not
// about the pixels — countable here, and only arguable from a screenshot. The
// tree's own rule is to measure what was **emitted** rather than what the
// producer believes it emitted, and a draw list is exactly the emission.
//
// What is left for the gate is the part that genuinely needs GL: that these
// rectangles reach the canvas at all.
use self::widgets::*;
use self::panel::*;
// A packed 0xRRGGBB colour, the same packing `graphics::rgb` produces. Kept as a
// plain integer so this package needs no graphics dependency to name one.
pub struct DrawRect {
dr_rect: UiRect,
dr_colour: integer,
}
// The panel's colours. A struct rather than constants because a consumer that
// cannot restyle the editor is a consumer being told what its editor looks like.
pub struct Theme {
th_panel_bg: integer,
th_button: integer,
th_button_sel: integer,
th_list_bg: integer,
th_item_sel: integer,
th_status_bg: integer,
th_subject_bg: integer,
th_separator: integer,
th_text: integer,
th_text_dim: integer, // the hotkey column — present, not shouting
}
// A dark theme that reads against a lit 3D world behind it.
pub fn theme_default() -> Theme {
Theme {
th_panel_bg: 0x1E1E22,
th_button: 0x33333A,
th_button_sel: 0x4E6E9E,
th_list_bg: 0x141418,
th_item_sel: 0x3A4E6E,
th_status_bg: 0x101014,
th_subject_bg: 0x22262E,
th_separator: 0x2A2A30,
th_text: 0xE0E0E6,
th_text_dim: 0x8A8A96,
}
}
// The panel as rectangles, back to front.
//
// ⚠ THE ORDER IS THE Z-ORDER, and the consumer draws it as given. Emitting the
// backgrounds after the things standing on them would paint over them, and that
// is a bug a picture shows and a count does not — so `test_the_backgrounds_come
// _first` pins it here instead.
pub fn panel_draw_list(p: Panel, th: Theme) -> vector<DrawRect> {
dl: vector<DrawRect> = [];
// the strip itself, then the two wells that sit on it
dl += [DrawRect { dr_rect: p.p_rect, dr_colour: th.th_panel_bg }];
dl += [DrawRect { dr_rect: p.p_list.lb_rect, dr_colour: th.th_list_bg }];
dl += [DrawRect { dr_rect: p.p_status.ss_rect, dr_colour: th.th_status_bg }];
// ⚠ THE SUBJECT BAR IS EMITTED WITH THE BACKGROUNDS, not after them, and that
// is only safe because nothing overlaps it: the strip starts below it. If it
// ever floated over the panel this order would paint the panel on top of the
// one line §C1 calls "never hidden".
dl += [DrawRect { dr_rect: p.p_subject.ss_rect, dr_colour: th.th_subject_bg }];
// a hairline above the list, so the toolbar reads as its own group
lr = p.p_list.lb_rect;
dl += [DrawRect {
dr_rect: rect(lr.r_x, lr.r_y - SEPARATOR_HEIGHT - 4, lr.r_w, SEPARATOR_HEIGHT),
dr_colour: th.th_separator,
}];
for b in p.p_toolbar {
dl += [DrawRect {
dr_rect: b.btn_rect,
dr_colour: if b.btn_selected { th.th_button_sel } else { th.th_button },
}];
}
// the selected row, if there is one and it is in view
if p.p_list.lb_selected >= 0 && p.p_list.lb_selected < len(p.p_list.lb_items) {
row_y = lr.r_y + p.p_list.lb_selected * p.p_list.lb_item_height - p.p_list.lb_scroll;
// ⚠ Clipped to the well. A selection scrolled out of view would otherwise be
// drawn ABOVE the list, over the toolbar — the failure mode of every
// hand-rolled list, and invisible until someone scrolls.
if row_y >= lr.r_y && row_y + p.p_list.lb_item_height <= lr.r_y + lr.r_h {
dl += [DrawRect {
dr_rect: rect(lr.r_x, row_y, lr.r_w, p.p_list.lb_item_height),
dr_colour: th.th_item_sel,
}];
}
}
dl
}
// ── the words ────────────────────────────────────────────────────
//
// A second list, for the same reason the first one is a list: turning a string
// into pixels needs a font and a GL texture, and neither belongs in a package
// whose tests run without a window.
//
// ⚠ EVERY STRING HERE IS ALREADY FITTED. `panel_build` truncated each label to
// its box, and this places what came out of that — so a caller cannot draw a
// label the panel never agreed would fit. That is why `Panel` carries its own
// `p_metrics` rather than taking them again here.
pub struct DrawText {
dt_text: text,
dt_x: integer,
dt_y: integer, // TOP of the line, which is what draw-a-texture wants
dt_colour: integer,
}
// Centre a line of text vertically in a box of height `h`.
fn baseline_in(box_y: integer, box_h: integer, line_h: integer) -> integer {
box_y + (box_h - line_h) / 2 ?? box_y
}
pub fn panel_text_list(p: Panel, th: Theme) -> vector<DrawText> {
tl: vector<DrawText> = [];
lh = p.p_metrics.m_line_h;
for b in p.p_toolbar {
r = b.btn_rect;
y = baseline_in(r.r_y, r.r_h, lh);
// the hotkey sits in its own column at the left, dimmer than the label
tl += [DrawText { dt_text: b.btn_hotkey_label,
dt_x: r.r_x + HOTKEY_X, dt_y: y, dt_colour: th.th_text_dim }];
tl += [DrawText { dt_text: b.btn_label,
dt_x: r.r_x + HOTKEY_COL, dt_y: y, dt_colour: th.th_text }];
}
lr = p.p_list.lb_rect;
i = 0;
for it in p.p_list.lb_items {
row_y = lr.r_y + i * p.p_list.lb_item_height - p.p_list.lb_scroll;
// ⚠ Clipped to the well, exactly as the selection highlight is. A row
// scrolled out of view would otherwise be drawn over the toolbar, and text
// floating above a list is the tell that nobody scrolled it while looking.
if row_y >= lr.r_y && row_y + p.p_list.lb_item_height <= lr.r_y + lr.r_h {
// ⚠ GREYED, NOT HIDDEN. The dim colour is the at-a-glance signal and the
// reason (already folded into the row's text by `panel_build`) is the
// sentence — §C3 wants both, because a grey row with no words says only
// that something is wrong.
tl += [DrawText { dt_text: it.en_label,
dt_x: lr.r_x + 6,
dt_y: baseline_in(row_y, p.p_list.lb_item_height, lh),
dt_colour: if it.en_available { th.th_text } else { th.th_text_dim } }];
}
i += 1;
}
sr = p.p_status.ss_rect;
tl += [DrawText { dt_text: p.p_status.ss_text,
dt_x: sr.r_x + 6,
dt_y: baseline_in(sr.r_y, sr.r_h, lh),
dt_colour: th.th_text }];
qr = p.p_subject.ss_rect;
tl += [DrawText { dt_text: p.p_subject.ss_text,
dt_x: qr.r_x + 6,
dt_y: baseline_in(qr.r_y, qr.r_h, lh),
dt_colour: th.th_text }];
tl
}
src/verbbar.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// THE VERB BAR — what you can press, and what presses it, in one strip.
//
// A row of slots along the bottom of the window. Each slot is a VERB the editor can
// do here, drawn with the KEY the person has bound to it. It is the picture of the
// definition layer ([EDITING_MODES § Phase 6](../../../doc/claude/EDITING_MODES.md)),
// and it is a **view** of that table rather than a second place the binding lives.
//
// ⛔ **WHY IT EXISTS: THE THING IT REPLACES WAS WRONG ON SCREEN.** The side panel's
// toolbar drew a hotkey glyph per button as a literal — `e` beside *Stencil* where `e`
// is `stair_up`, `c` beside *Cart* where `c` is `cellar`, `f` beside *Field* where `f`
// is `fence`. Three of six, for months, because a literal glyph is connected to
// nothing that could disagree with it. Everything below takes its glyph from the
// caller's map, so a rebind moves the label with the binding.
//
// ── ⚠ THE TWO DESIGN CALLS, because both could reasonably have gone the other way ──
//
// **1. Slots are NOT numbered.** There is no `1..9` convention: a slot shows whatever
// key its verb is bound to, and an unbound verb shows a blank. World of Warcraft's
// numbers are a consequence of its bars being PAGES of fixed size; here a verb has a
// letter because a letter is where an author's hand already is. ⚠ Numbering would also
// quietly make the bar a second binding surface — *slot 3 is verb X* — which is the
// duplication the whole phase exists to remove.
//
// **2. One row, and overflow is REPORTED rather than wrapped.** A bar that grows a
// second row is a bar that eats the window it is drawn over, and the ask was for
// something that *doesn't take up too much screen space*. So it keeps what fits and
// says how many it could not show — `vb_hidden`, which a consumer draws as `+3`.
// ⏭ The real answer to overflow is the MODE (`D1`): the verbs available where you are
// standing are a handful, not the whole vocabulary, and this bar shrinks for free the
// day that query narrows.
//
// ⚠ **AND THIS FILE KNOWS NOTHING ABOUT VERBS**, exactly as `panel.loft` knows nothing
// about what it is showing. It takes labels, key glyphs and an armed flag; the words
// `opening` and `cellar` never appear here. That is what lets a different editor with
// a different vocabulary use it unchanged.
use self::widgets::*;
use self::panel::*;
use self::font::*;
// `DrawRect`, `DrawText` and `Theme` — the picture as lists, which is `render.loft`'s
// shape and this file's for its reason: no GL here, so the tests need no window.
use self::render::*;
// ── Layout constants ─────────────────────────────────────────────
//
// ⚠ **SMALL ON PURPOSE, AND THE WIDTH IS A MEASUREMENT RATHER THAN A TASTE.** The
// content is a 4-character short label under a key glyph; the editor's font measures
// **10px per character, mono** (`client: panel … advance 10`), so the label is 40px and
// the slot is that plus 3px each side. Anything bigger is a toolbar, and a toolbar is
// what this replaces.
//
// ⛔ **IT WAS 34, WHICH IS THE WIDTH A DIFFERENT FONT WANTED.** Guessed at an 8px
// advance and shipped: the first real run drew every label truncated to one character
// plus an ellipsis — `fenc` as `f…` — while the layout suite stayed green, because the
// only claim it made was *the text is inside its box* and truncated text always is.
// `test_a_four_character_label_survives_the_editors_own_metrics` is the row that says
// the slot is wide enough for the content it exists to hold.
pub VERB_SLOT_W = 46;
// ⚠ **TWO LINES, AND THE HEIGHT IS THE SECOND MEASUREMENT THIS WIDGET GOT WRONG BY
// GUESSING.** The editor draws at `UI_SIZE` 16.0, which measures ~16px per line; a slot
// holding a key over a label needs both plus a little air. ⛔ It was 30 — enough for
// one line and four pixels — so the first real run drew `Up` and `rise` ON TOP OF EACH
// OTHER, while the layout suite stayed green because its only claim was that the key's
// y is LESS than the label's. Ordering is not separation.
// `test_the_two_lines_of_a_slot_do_not_overlap_at_the_editors_metrics` is the row that
// says so now.
pub VERB_SLOT_H = 40;
pub VERB_SLOT_GAP = 3;
// The margin between the bar and the bottom of the window, and between the bar and
// the panel on its left.
pub VERB_BAR_PAD = 6;
// ⚠ THE BAR'S HEIGHT IS THE SLOT'S, and it is a constant rather than a sum, because a
// consumer needs to reserve the strip before it knows how many slots there are.
pub fn verbbar_height() -> integer { VERB_SLOT_H + VERB_BAR_PAD * 2 }
// ── What a consumer hands over ───────────────────────────────────
//
// ⚠ `sv_verb` IS CARRIED THROUGH UNTOUCHED AND NEVER DRAWN. It is what a click means —
// *rebind THIS verb* — and a consumer that had to map a slot index back to a verb
// would be keeping a second list in step with this one. `panel.loft` uses indices for
// its list because a catalogue row has no name that outlives the list; a verb does.
pub struct SpecVerb {
sv_verb: text, // the published name — what a rebind names
sv_label: text, // the short label the slot draws, already fitted by the caller
sv_key: text, // the key glyph, "" when the verb is bound to nothing
sv_on: boolean, // armed / mid-gesture — a run with one end down, say
}
pub fn spec_verb(verb: text, label: text, key: text) -> SpecVerb {
SpecVerb { sv_verb: verb, sv_label: label, sv_key: key, sv_on: false }
}
pub fn spec_verb_on(verb: text, label: text, key: text, on: boolean) -> SpecVerb {
SpecVerb { sv_verb: verb, sv_label: label, sv_key: key, sv_on: on }
}
// ── The laid-out bar ─────────────────────────────────────────────
pub struct VerbSlot {
vt_rect: UiRect,
vt_verb: text,
vt_label: text,
vt_key: text,
vt_on: boolean,
}
pub struct VerbBar {
vb_rect: UiRect,
vb_slots: vector<VerbSlot>,
// How many the strip could not show. ⚠ **REPORTED, NEVER SILENTLY DROPPED** — a bar
// that truncated quietly would read as *those verbs do not exist here*, which is
// exactly the wrong sentence to put in front of an author who cannot find one.
vb_hidden: integer,
}
// Lay the bar out along the bottom of the window, to the right of the side panel.
//
// ⚠ **IT TAKES THE PANEL'S WIDTH RATHER THAN ASSUMING IT.** A consumer with no side
// panel passes 0 and gets a full-width bar; this package must not decide that an
// editor has a panel at all.
//
// ⚠ **AND THE STATUS STRIP IS ABOVE IT, NOT BELOW** — `panel.loft` puts the status
// across the bottom of the WINDOW, so the bar sits clear of it. Two things claiming
// the last 24 rows of pixels is the overlap `panel_draw_list`'s own comment warns
// about for the subject bar, one edge over.
// ⚠ **IT FITS ITS OWN TEXT, WHICH IS `panel_build`'s CONTRACT ONE WIDGET OVER.** That
// file's `panel_text_list` says *"every string here is already fitted … so a caller
// cannot draw a label the panel never agreed would fit"*, and the same has to hold
// here or a long key glyph runs out of its slot and across its neighbour. ⚠ It is a
// BACKSTOP and not the answer: two names sharing a prefix truncate to the same thing,
// so a caller that wants them distinguishable shortens them itself (`key_glyph`).
pub fn verbbar_build(specs: vector<SpecVerb>, win_w: integer, win_h: integer,
panel_w: integer, m: Metrics) -> VerbBar {
x0 = panel_w + VERB_BAR_PAD;
avail = win_w - x0 - VERB_BAR_PAD;
bar_h = verbbar_height();
y0 = win_h - STATUS_HEIGHT - bar_h;
bar = rect(x0, y0, if avail > 0 { avail } else { 0 }, bar_h);
// How many fit: n slots and n-1 gaps.
//
// ⚠ **THE ARITHMETIC IS WRITTEN AS A LOOP RATHER THAN A DIVISION**, because the
// closed form is off by one at the boundary in the direction that OVERFLOWS — a
// slot half outside the bar is the failure a division silently produces, and the
// rows in `tests/verbbar.loft` that assert containment are what found it.
slots: vector<VerbSlot> = [];
hidden = 0;
cx = x0 + VERB_BAR_PAD;
for s in specs {
right = cx + VERB_SLOT_W;
if right <= x0 + bar.r_w - VERB_BAR_PAD {
slots += [VerbSlot {
vt_rect: rect(cx, y0 + VERB_BAR_PAD, VERB_SLOT_W, VERB_SLOT_H),
vt_verb: s.sv_verb,
vt_label: fit_text(s.sv_label, VERB_SLOT_W - 2, m),
vt_key: fit_text(s.sv_key, VERB_SLOT_W - 2, m),
vt_on: s.sv_on,
}];
cx = right + VERB_SLOT_GAP;
} else {
hidden = hidden + 1;
}
}
VerbBar { vb_rect: bar, vb_slots: slots, vb_hidden: hidden }
}
// Which slot is under the pointer, or -1.
//
// ⚠ **IT ANSWERS AN INDEX AND THE CALLER READS THE VERB OFF IT**, rather than
// answering the verb directly: -1 has to mean *nothing*, and a text return would have
// to spell that as `""`, which is also a legal verb name in the caller's world (it is
// `VB_NONE`). An integer cannot be confused with an answer.
pub fn verbbar_hit(b: VerbBar, mx: integer, my: integer) -> integer {
i = 0;
for s in b.vb_slots {
if rect_contains(s.vt_rect, mx, my) { return i; }
i += 1;
}
0 - 1
}
// The verb a slot names, or "" when the index is not a slot.
pub fn verbbar_verb(b: VerbBar, i: integer) -> text {
if i < 0 || i >= len(b.vb_slots) { return ""; }
(b.vb_slots[i] ?? VerbSlot {}).vt_verb
}
// ── The picture, as lists ────────────────────────────────────────
//
// Same shape and same reason as `panel_draw_list`: rectangles and strings, so this
// package needs no GL and its tests need no window.
pub fn verbbar_draw_list(b: VerbBar, th: Theme) -> vector<DrawRect> {
dl: vector<DrawRect> = [];
dl += [DrawRect { dr_rect: b.vb_rect, dr_colour: th.th_panel_bg }];
for s in b.vb_slots {
dl += [DrawRect {
dr_rect: s.vt_rect,
dr_colour: if s.vt_on { th.th_button_sel } else { th.th_button },
}];
}
dl
}
// ⚠ **TWO LINES PER SLOT, KEY OVER LABEL, AND THE ORDER IS THE POINT.** The question a
// bar answers at a glance is *which key does this*, so the key is the top line and the
// label reads under it. Putting the label first makes the bar a list of features and
// the key an annotation, which is the toolbar it replaces.
//
// ⚠ **AN UNBOUND VERB DRAWS A BLANK TOP LINE RATHER THAN BEING HIDDEN.** The slot says
// *this exists and you have no key for it*, which is the only honest reading after a
// rebind displaces something — `keymap_bind` leaves the displaced verb in the list
// precisely so this slot can appear.
pub fn verbbar_text_list(b: VerbBar, th: Theme, m: Metrics) -> vector<DrawText> {
tl: vector<DrawText> = [];
for s in b.vb_slots {
r = s.vt_rect;
// ⚠ **BOTH LINES ARE PLACED FROM THE METRICS, NOT FROM TWO MAGIC OFFSETS.** The
// pair has to be centred as a BLOCK — a key pinned to the top and a label pinned
// to the bottom overlap the moment the font is taller than the slot expected,
// which is exactly how the first version drew `Up` through `rise`.
pad = (r.r_h - m.m_line_h * 2) / 2 ?? 0;
if pad < 0 { pad = 0; }
kw = text_width(s.vt_key, m);
tl += [DrawText {
dt_text: s.vt_key,
dt_x: r.r_x + (r.r_w - kw) / 2 ?? r.r_x,
dt_y: r.r_y + pad,
dt_colour: th.th_text,
}];
// …and the short label directly under it, dimmer, because the key is the answer.
lw = text_width(s.vt_label, m);
tl += [DrawText {
dt_text: s.vt_label,
dt_x: r.r_x + (r.r_w - lw) / 2 ?? r.r_x,
dt_y: r.r_y + pad + m.m_line_h,
dt_colour: th.th_text_dim,
}];
}
// ⚠ THE OVERFLOW COUNT IS DRAWN, which is what makes `vb_hidden` a report rather
// than a field nobody reads. It sits after the last slot, in the dim colour, so it
// reads as *there is more* and not as another verb.
if b.vb_hidden > 0 {
lastx = b.vb_rect.r_x + VERB_BAR_PAD;
for s2 in b.vb_slots { lastx = s2.vt_rect.r_x + s2.vt_rect.r_w + VERB_SLOT_GAP; }
tl += [DrawText {
dt_text: "+{b.vb_hidden}",
dt_x: lastx + 2,
dt_y: b.vb_rect.r_y + VERB_BAR_PAD + (VERB_SLOT_H - m.m_line_h) / 2 ?? 0,
dt_colour: th.th_text_dim,
}];
}
tl
}
src/widgets.loft
// Widget primitives for the walkable-editor in-engine UI.
// Pure data + hit-test helpers; no rendering (that lives in
// panel_render once loft_gl's sprite / text bridges are wired).
// ⚠ `UiRect`, NOT `Rect` — plan 19 `L6`. The registry's `shapes` publishes a `Rect`
// of four FLOATS; this one is four integer pixels, and a UI that snapped to a float
// rectangle would be the whole reason this package has its own. Nothing imports both
// today, which is exactly when the rename is still free: `lavition_ui` is published at
// `L7`, and a published name cannot be renamed. `tools/names.sh` found it.
pub struct UiRect {
r_x: integer,
r_y: integer,
r_w: integer,
r_h: integer,
}
pub fn rect(x: integer, y: integer, w: integer, h: integer) -> UiRect {
UiRect { r_x: x, r_y: y, r_w: w, r_h: h }
}
pub fn rect_contains(r: UiRect, mx: integer, my: integer) -> boolean {
mx >= r.r_x && mx < r.r_x + r.r_w && my >= r.r_y && my < r.r_y + r.r_h
}
// A clickable button in the toolbar. `btn_id` is the integer
// identity the panel uses in hit-test results (0..5 = ToolKind).
pub struct Button {
btn_id: integer,
btn_rect: UiRect,
btn_label: text,
btn_hotkey_label: text, // e.g. "1" shown left of the label
btn_selected: boolean,
}
// One row of a catalogue: what it is called, and whether you can have it.
//
// ⚠ AVAILABILITY IS PART OF THE ENTRY, NOT A SEPARATE DIALOG (CATALOGUE §C3).
// A thing you cannot use is shown GREYED WITH ITS REASON rather than hidden:
// hiding it makes the author think it is missing, while showing it with
// `too wide for frame/2x3` tells them what to change. It is §C2's refusal rule —
// reason, and never a blank no — applied to a list instead of to a gesture.
// ⚠ AND IT CARRIES ITS KIND (§C3). A catalogue entry is *kind · name · image ·
// availability*, and ONE widget serves both families — a material and a part are
// each *a named thing you pick and then place*. The kind is what lets a consumer
// group them, filter them, or send the right message when one is chosen, without
// a second list existing to be kept in step with the first.
//
// It is TEXT rather than an enum because `lavition_ui` must not know what families
// exist: a layout library that enumerated `material | part` would have to be
// edited before a consumer could invent a third.
pub const KIND_UNSET = "";
pub struct Entry {
en_kind: text, // "material", "part", … — the consumer's vocabulary
en_label: text,
en_available: boolean,
en_reason: text, // "" when available; required when not
}
pub fn entry(label: text) -> Entry {
Entry { en_kind: KIND_UNSET, en_label: label, en_available: true, en_reason: "" }
}
pub fn entry_of(kind: text, label: text) -> Entry {
Entry { en_kind: kind, en_label: label, en_available: true, en_reason: "" }
}
// ⚠ THE REASON IS NOT OPTIONAL HERE. An entry that is unavailable "because" is
// the blank no §C2 exists to forbid, so a missing reason becomes a visible
// placeholder rather than an empty string nobody notices.
pub fn entry_blocked(label: text, why: text) -> Entry {
return entry_blocked_of(KIND_UNSET, label, why);
}
pub fn entry_blocked_of(kind: text, label: text, why: text) -> Entry {
Entry { en_kind: kind, en_label: label, en_available: false,
en_reason: if why == "" { "no reason given" } else { why } }
}
// Scrollable list of catalogue entries. `lb_selected` is the currently
// highlighted index, or -1 for no selection.
pub struct ListBox {
lb_rect: UiRect,
lb_items: vector<Entry>,
lb_selected: integer,
lb_scroll: integer, // pixel scroll offset
lb_item_height: integer,
}
// Single-line status strip at the bottom of the side panel.
pub struct StatusStrip {
ss_rect: UiRect,
ss_text: text,
}
tests/hit_test.loft
// Hit-test — every arm, and the gaps between them.
//
// ⚠ NO WORLD AND NO TOOL STATE. These were `moros_ui`'s and each built a
// `ToolState` to click on a rectangle. A hit is a number; if testing one needs a
// physics package linked, the hit-test knows about physics.
//
// ⚠ AND `route_click` IS GONE, not ported. It was `panel_hit_test` plus a
// mutation of the caller's tool state — and only the consumer knows what a click
// on item 3 MEANS. With the mutation removed it was this function under a second
// name, so the tests that exercised it now exercise this one.
use lavition_ui::*;
fn ui_metrics(advance: integer, line_h: integer) -> Metrics {
run = (advance * 10) as float;
metrics_measured(run, run, 10, line_h)
}
fn is_button(h: UiHit) -> boolean {
if h is UhToolButton { tb_id } { _ = tb_id; return true; }
false
}
fn is_item(h: UiHit) -> boolean {
if h is UhListItem { li_idx } { _ = li_idx; return true; }
false
}
fn six_button_panel() -> Panel {
spec = PanelSpec {
ps_buttons: [
spec_button("None", "1"), spec_button("Raise", "2"), spec_button("Lower", "3"),
spec_button("Stencil", "4"), spec_button("Item", "5"), spec_button("Wall", "6"),
],
ps_selected_button: 0,
ps_items: [entry("1"), entry("2"), entry("4"), entry("8")],
ps_selected_item: 0,
ps_status: "ready",
};
panel_build(spec, 1280, 720, ui_metrics(8, 8))
}
// ── Outside the panel ────────────────────────────────────────────
fn test_click_right_of_panel_is_world() {
hit = panel_hit_test(six_button_panel(), 500, 300);
assert(hit is UhWorld, "a click right of the strip is the world");
}
// ── The toolbar ──────────────────────────────────────────────────
fn test_click_on_each_button_reports_its_own_id() {
p = six_button_panel();
for i in 0..6 {
r = toolbar_button_rect(i);
hit = panel_hit_test(p, r.r_x + 4, r.r_y + 4);
// ⚠ NO CAPTURE LIST IN THE ASSERT — plan-free, 2026-08-19. `{ tb_id }` after a
// type is the FIELD-CAPTURE list and wants a block after it, so it cannot stand in
// expression position; the loft installed 19 Aug says so by name and offers `match`.
// This assert only asks WHICH VARIANT, and the `if` below is where the field is
// read — so the capture was never doing anything here.
assert(hit is UhToolButton, "button {i} was not hit");
if hit is UhToolButton { tb_id } {
assert(tb_id == i, "clicked button {i}, got id {tb_id}");
}
}
}
// ⚠ THE GAP BETWEEN TWO BUTTONS IS NOT EITHER OF THEM. Without this the test
// above passes on a hit-test that returns the nearest button for any y at all.
fn test_the_gap_between_buttons_is_not_a_button() {
p = six_button_panel();
b0 = toolbar_button_rect(0);
hit = panel_hit_test(p, b0.r_x + 4, b0.r_y + b0.r_h + 1);
assert(!is_button(hit), "the gap under button 0 reported a button");
}
// ── The list ─────────────────────────────────────────────────────
fn test_click_on_a_list_item_reports_its_index() {
p = six_button_panel();
lr = p.p_list.lb_rect;
for i in 0..4 {
hit = panel_hit_test(p, lr.r_x + 4, lr.r_y + i * LIST_ITEM_HEIGHT + 2);
assert(hit is UhListItem, "list row {i} was not hit");
if hit is UhListItem { li_idx } {
assert(li_idx == i, "clicked row {i}, got index {li_idx}");
}
}
}
// ⚠ PAST THE LAST ITEM IS NOT AN ITEM. The list rectangle is taller than its
// contents, so a hit-test that divided by the row height and stopped would name
// a row that is not there.
fn test_click_below_the_last_item_is_not_an_item() {
p = six_button_panel();
lr = p.p_list.lb_rect;
hit = panel_hit_test(p, lr.r_x + 4, lr.r_y + 8 * LIST_ITEM_HEIGHT + 2);
assert(!is_item(hit), "a row past the end reported an item");
}
// The scroll offset shifts which row a pixel names — the case with no picture.
fn test_scrolling_moves_which_row_a_click_names() {
p = six_button_panel();
lr = p.p_list.lb_rect;
y = lr.r_y + 2;
before = panel_hit_test(p, lr.r_x + 4, y);
p.p_list.lb_scroll = LIST_ITEM_HEIGHT;
after = panel_hit_test(p, lr.r_x + 4, y);
assert(is_item(before) && is_item(after), "both should be items");
if before is UhListItem { li_idx } {
b = li_idx;
if after is UhListItem { li_idx } {
assert(after_differs(b, li_idx), "scrolling one row did not change the index: {b} then {li_idx}");
}
}
}
fn after_differs(a: integer, b: integer) -> boolean { a != b }
// ── The row rectangle, against the hit-test that has to agree with it ────────
//
// ⚠ THE ROUND TRIP IS THE CLAIM, not the arithmetic. `list_row_rect` and
// `panel_hit_test` are one layout read in two directions, and the only way they
// can be wrong is by disagreeing — so the test asks the hit-test where the
// rectangle's own centre lands and requires the row back. Checking the numbers
// against numbers would be the table checked against the table.
fn row_centre_hits_its_own_row(p: Panel, why: text) {
seen = 0;
for i in 0..len(p.p_list.lb_items) {
r = list_row_rect(p, i);
cx = r.r_x + r.r_w / 2 ?? 0;
cy = r.r_y + r.r_h / 2 ?? 0;
// ⚠ A ROW SCROLLED OUT OF THE STRIP HAS A POSITION AND NO HIT, which is the
// documented split between these two and not a disagreement: `list_row_rect`
// answers *where is row i*, `panel_hit_test` answers *what is under this
// pixel*, and a pixel outside the list is under nothing. Asking the hit-test
// about it would be asking it the other function's question. ⚠ This `continue`
// is why the count below exists — a filter that quietly removed every row
// would leave a green test that examined nothing.
if !rect_contains(p.p_list.lb_rect, cx, cy) { continue; }
seen += 1;
hit = panel_hit_test(p, cx, cy);
assert(is_item(hit), "{why}: row {i}'s own centre ({cx},{cy}) is inside the "
+ "list and is not an item at all");
if hit is UhListItem { li_idx } {
assert(li_idx == i, "{why}: row {i}'s own centre hit-tests as row {li_idx}");
}
}
assert(seen > 0, "{why}: no row's centre landed inside the list — nothing was tested");
}
fn test_every_rows_centre_hit_tests_back_to_that_row() {
row_centre_hits_its_own_row(six_button_panel(), "unscrolled");
}
// ⚠ SCROLLED IS THE CASE THAT SEPARATES THE TWO. Unscrolled, a row rectangle
// that forgot the scroll offset agrees with the hit-test at every index — the
// term is zero — so a test that never scrolls cannot tell a correct inverse from
// one that ignores half its input. Measured: with the term dropped this fails at
// row 0 and the unscrolled test above stays green.
fn test_the_row_rectangle_follows_the_scroll() {
p = six_button_panel();
p.p_list.lb_scroll = LIST_ITEM_HEIGHT;
row_centre_hits_its_own_row(p, "scrolled one row");
}
// ⚠ AN INDEX WITH NO ROW GETS NO RECTANGLE. Returning the geometry of a row that
// is not there is what makes an off-by-one draw a phantom entry instead of
// nothing, so the empty rectangle is the answer and it is asserted rather than
// assumed.
fn test_an_index_past_the_end_has_no_rectangle() {
p = six_button_panel();
n = len(p.p_list.lb_items);
for bad in [-1, n, n + 5] {
r = list_row_rect(p, bad);
assert(r.r_w == 0 && r.r_h == 0,
"index {bad} of {n} answered a {r.r_w}x{r.r_h} rectangle");
}
}
// ── Empty panel ──────────────────────────────────────────────────
fn test_an_empty_panel_hits_nothing_inside_the_strip() {
empty = PanelSpec {
ps_buttons: [], ps_selected_button: -1,
ps_items: [], ps_selected_item: -1, ps_status: "",
};
p = panel_build(empty, 1280, 720, ui_metrics(8, 8));
hit = panel_hit_test(p, 20, 20);
assert(hit is UhNone, "an empty panel reported a hit");
assert(panel_hit_test(p, 900, 300) is UhWorld, "outside is still the world");
}
tests/layout.loft
// Panel layout — rects, and the spec that fills them.
//
// ⚠ THESE TESTS LINK NOTHING. They were `moros_ui`'s and each one built a
// `ToolState` to assert a rectangle, which linked a player-physics and collision
// package into a layout test. A panel is rectangles and strings; if a layout
// test needs a world to run, the layout knows about the world.
use lavition_ui::*;
// A fixed-pitch Metrics — built through `metrics_measured`, the same door a
// consumer uses, because there is deliberately no "just tell me the advance"
// constructor to skip the measuring with.
fn ui_metrics(advance: integer, line_h: integer) -> Metrics {
run = (advance * 10) as float;
metrics_measured(run, run, 10, line_h)
}
// Six buttons and a few items — Moros's shape, as an EXAMPLE rather than as the
// library's own idea of what a panel contains.
fn demo_spec() -> PanelSpec {
PanelSpec {
ps_buttons: [
spec_button("None", "1"), spec_button("Raise", "2"), spec_button("Lower", "3"),
spec_button("Stencil", "4"), spec_button("Item", "5"), spec_button("Wall", "6"),
],
ps_selected_button: 3,
ps_items: [entry("none"), entry("solid"), entry("half"), entry("thick_flat"), entry("thick_curved")],
ps_selected_item: 1,
ps_status: "q=0 r=0 cy=0 y= 0.0 FPS 60",
ps_subject: "world hollow-hill · AUTO · level ON · 30 Hz",
}
}
// ── UiRect helpers ─────────────────────────────────────────────────
fn test_rect_contains_inside() {
r = rect(10, 10, 100, 50);
assert(rect_contains(r, 50, 30), "centre inside");
}
fn test_rect_contains_boundaries() {
r = rect(10, 10, 100, 50);
assert(rect_contains(r, 10, 10), "top-left corner inside");
assert(!rect_contains(r, 110, 10), "right edge exclusive");
assert(!rect_contains(r, 10, 60), "bottom edge exclusive");
assert(!rect_contains(r, 9, 10), "just-left outside");
assert(!rect_contains(r, 10, 9), "just-above outside");
}
// ── Layout at standard resolution ────────────────────────────────
fn test_panel_rect_starts_below_the_subject_bar() {
r = panel_rect(1280, 720);
assert(r.r_x == 0, "panel starts at the left edge");
// ⚠ NOT y=0. §C1's subject line is "never hidden", and a strip drawn from the
// top would cover its left end — which is exactly where it starts reading.
assert(r.r_y == SUBJECT_HEIGHT, "panel starts below the subject bar, got y={r.r_y}");
assert(r.r_w == 240, "panel w = 240");
assert(r.r_h == 720 - SUBJECT_HEIGHT, "panel h is the window less the subject bar");
}
fn test_the_subject_bar_spans_the_window_at_the_top() {
q = subject_rect(1280);
assert(q.r_x == 0 && q.r_y == 0, "the subject bar is at the very top-left");
assert(q.r_w == 1280, "it spans the WINDOW — its line runs to 70 characters");
assert(q.r_h == SUBJECT_HEIGHT, "fixed height");
}
// The two bars must not meet in the middle of a short window.
fn test_the_two_bars_never_overlap() {
for h in [200, 480, 720, 1080] {
q = subject_rect(1280);
st = status_rect(1280, h);
assert(q.r_y + q.r_h <= st.r_y, "subject and status bars collide at height {h}");
}
}
fn test_toolbar_buttons_stack_without_overlapping() {
b0 = toolbar_button_rect(0);
b1 = toolbar_button_rect(1);
b5 = toolbar_button_rect(5);
assert(b0.r_y == SUBJECT_HEIGHT + 8, "first button below the subject bar, got {b0.r_y}");
assert(b1.r_y >= b0.r_y + b0.r_h, "button 1 starts below button 0");
assert(b5.r_y + b5.r_h < 280, "six buttons stay in the top quarter");
}
fn test_toolbar_button_width() {
b = toolbar_button_rect(0);
assert(b.r_w == 240 - 16, "button spans the strip minus its margins");
}
// ⚠ THE BUTTON COUNT IS A PARAMETER NOW, and this is what says so. It was the
// constant `TOOLBAR_BUTTONS = 6` — Moros's tool count baked into every
// consumer's geometry.
fn test_the_list_starts_below_however_many_buttons_there_are() {
three = list_rect(720, 3);
six = list_rect(720, 6);
assert(six.r_y > three.r_y, "six buttons push the list further down than three");
assert(six.r_h < three.r_h, "and leave it less room");
assert(three.r_h > 0 && six.r_h > 0, "both leave a usable list");
}
fn test_list_rect_non_empty_at_common_sizes() {
for h in [480, 720, 800, 1080] {
r = list_rect(h, 6);
assert(r.r_h > 0, "list has no room at window height {h}");
}
}
fn test_status_bar_spans_the_window_not_the_panel() {
s = status_rect(1280, 720);
assert(s.r_y == 720 - 24, "status anchored to the bottom");
assert(s.r_h == 24, "status height fixed");
assert(s.r_x == 0, "status starts at the left edge");
assert(s.r_w == 1280, "status spans the WINDOW — see status_rect");
}
// ── panel_build ──────────────────────────────────────────────────
fn test_panel_build_makes_one_button_per_spec_entry() {
p = panel_build(demo_spec(), 1280, 720, ui_metrics(8, 8));
assert(len(p.p_toolbar) == 6, "six spec buttons, {len(p.p_toolbar)} built");
}
fn test_panel_build_marks_the_selected_button() {
p = panel_build(demo_spec(), 1280, 720, ui_metrics(8, 8));
b3 = p.p_toolbar[3] ?? Button {};
b0 = p.p_toolbar[0] ?? Button {};
assert(b3.btn_selected, "index 3 was named selected");
assert(!b0.btn_selected, "index 0 was not");
}
fn test_panel_build_carries_the_items_and_the_selection() {
p = panel_build(demo_spec(), 1280, 720, ui_metrics(8, 8));
assert(len(p.p_list.lb_items) == 5, "five items, got {len(p.p_list.lb_items)}");
assert(p.p_list.lb_selected == 1, "selection index passes through");
}
// ⚠ THE KIND SURVIVES THE BUILD, AND IT DID NOT. `panel_build` rebuilds every
// `Entry` to fit its label, and the rebuild simply left `en_kind` out — so a
// spec carrying two families came out of the panel as one, every row
// `KIND_UNSET`. Nothing failed: `widgets.loft` tests the kind on the
// CONSTRUCTORS and the wire carried it correctly, so the field was right
// everywhere except in the only structure a renderer reads.
//
// The claim is stated over a MIXED list, because a list of one kind cannot
// tell "carried" from "defaulted to whatever the first row was".
fn test_panel_build_carries_the_kind_of_every_family() {
s = demo_spec();
s.ps_items = [entry_of("material", "grass"),
entry_of("part", "house/cottage"),
entry_blocked_of("material", "tree", "scattered")];
p = panel_build(s, 1280, 720, ui_metrics(8, 8));
got: vector<text> = [];
for it in p.p_list.lb_items { got += [it.en_kind]; }
assert(len(got) == 3, "three items, got {len(got)}");
assert(got[0] == "material", "row 0 kind is '{got[0]}'");
assert(got[1] == "part", "row 1 kind is '{got[1]}' — the family did not survive the build");
assert(got[2] == "material", "row 2 kind is '{got[2]}' — a blocked row lost its family");
}
// ⚠ AND AN UNSET KIND STAYS UNSET. The fix must carry the field, not invent one:
// a `panel_build` that defaulted every row to "material" would pass the test
// above for the rows that ARE materials and quietly relabel everything else.
fn test_panel_build_does_not_invent_a_kind() {
s = demo_spec();
s.ps_items = [entry("solid")];
p = panel_build(s, 1280, 720, ui_metrics(8, 8));
assert((p.p_list.lb_items[0] ?? entry("x")).en_kind == KIND_UNSET,
"panel_build gave an unkinded row a family of its own");
}
// An empty panel is a legal panel: a consumer with no tools yet still gets a
// strip and a status bar rather than a null.
fn test_an_empty_spec_still_builds() {
empty = PanelSpec {
ps_buttons: [], ps_selected_button: -1,
ps_items: [], ps_selected_item: -1,
ps_status: "",
ps_subject: "",
};
p = panel_build(empty, 1280, 720, ui_metrics(8, 8));
assert(len(p.p_toolbar) == 0, "no buttons");
assert(len(p.p_list.lb_items) == 0, "no items");
assert(p.p_list.lb_rect.r_h > 0, "the list still has a rectangle");
assert(p.p_rect.r_w == 240, "the strip is still there");
}
fn test_a_selection_outside_the_buttons_marks_nothing() {
s = demo_spec();
s.ps_selected_button = 99;
p = panel_build(s, 1280, 720, ui_metrics(8, 8));
for b in p.p_toolbar {
assert(!b.btn_selected, "button {b.btn_id} marked by an out-of-range selection");
}
}
// ⚠ THE 2-PIXEL OVERLAP, ASSERTED RATHER THAN DESCRIBED. `hotkey_room`'s own
// comment records the defect it was written to close — the hotkey was fitted to
// `HOTKEY_COL - 4` and drawn at `HOTKEY_X`, which left it two pixels longer than
// the gap and put it straight back under the label it was being moved out of. A
// comment cannot go red, and this function had no test and no caller outside its
// own package at all.
//
// ⚠ THE CLAIM IS THE ROUND TRIP BETWEEN THE TWO SITES, not the arithmetic. `render`
// draws the hotkey at `r_x + HOTKEY_X`; `panel_build` fits it to `hotkey_room()`.
// Those are the two numbers that have to meet, and either one alone is a number
// checked against itself.
fn test_a_hotkey_fitted_to_its_room_never_reaches_the_label() {
for adv in [4, 8, 16, 32] {
m = ui_metrics(adv, 8);
// The widest thing a hotkey can be — every fitted result is at most this.
cut = fit_text("WWWW", hotkey_room(), m);
right = HOTKEY_X + text_width(cut, m);
assert(right <= HOTKEY_COL,
"at advance {adv} a hotkey drawn at {HOTKEY_X} runs to {right}, and the "
+ "label starts at {HOTKEY_COL} — they overlap by {right - HOTKEY_COL}");
}
}
// ⚠ AND THE GAP IS NOT ZERO, because *they do not overlap* and *they do not touch*
// are different claims and only the second is what the constant is for. Without
// this a `HOTKEY_GAP` of 0 would satisfy the test above at every advance.
fn test_the_hotkey_and_the_label_never_touch() {
assert(HOTKEY_X + hotkey_room() + HOTKEY_GAP == HOTKEY_COL,
"the three distances do not add up to the column: {HOTKEY_X} + "
+ "{hotkey_room()} + {HOTKEY_GAP} is not {HOTKEY_COL}");
assert(HOTKEY_GAP > 0, "a gap of {HOTKEY_GAP} lets the hotkey touch the label");
}
tests/metrics.loft
// The metrics seam — plan 18, `B1.2`, re-homed by `B1.2b`.
//
// `font.loft` used to claim `text_width(s) = len(s) * 8`. `probe/b1` measured
// what the bridge actually gives and the 8 was wrong on BOTH targets: 19.27 on
// the desktop, 26.66 in the browser, and the browser's font was not even fixed
// pitch. A panel laid out on a made-up advance mis-lays every row and nothing
// reports it, because the text still draws.
//
// So the library does arithmetic on numbers the CONSUMER measured, and these
// tests pin the arithmetic. They run with no window and no GL context, which is
// the constraint that forced the seam in the first place.
use lavition_ui::*;
fn mono(advance: integer) -> Metrics {
run = (advance * 10) as float;
metrics_measured(run, run, 10, advance)
}
// ── the fixed-pitch instrument ───────────────────────────────────
// ⚠ THE CLAIM `len * advance` IS EXACT RESTS ENTIRELY ON THIS. Two runs of the
// SAME LENGTH and OPPOSITE WIDTH is the whole instrument; one width cannot
// answer it, because ten M's are ten M's in any font.
fn test_two_equal_runs_are_fixed_pitch() {
m = metrics_measured(192.65625, 192.65625, 10, 38);
assert(m.m_mono, "equal M-run and i-run must read as fixed pitch");
assert(metrics_advance(m) == 19, "advance from the wide run: {metrics_advance(m)}, want 19");
// and the fine unit it is actually kept in: 19.265625 * 64 = 1233
assert(m.m_adv64 == 1233, "adv64 is {m.m_adv64}, want 1233 (19.265625 px)");
}
// ⚠ THE CONTROL, AND IT IS A REAL MEASUREMENT — these are the numbers
// `probe/b1` read out of headless Chrome when the browser resolved
// `DejaVuSansMono.ttf` to a proportional fallback. If this test ever passes as
// mono, the check that protects every layout below has stopped working.
fn test_the_browser_fallback_is_caught_as_proportional() {
m = metrics_measured(266.5625, 71.09375, 10, 36);
assert(!m.m_mono,
"266.56 vs 71.09 is proportional and must not read as fixed pitch");
}
fn test_a_zero_length_run_is_not_fixed_pitch() {
// A consumer that measured nothing must not get a confident answer.
m = metrics_measured(0.0, 0.0, 0, 12);
assert(!m.m_mono, "n=0 cannot establish fixed pitch");
assert(metrics_advance(m) == 0, "advance from no measurement is 0");
}
// ── width and fit ────────────────────────────────────────────────
fn test_width_is_length_times_advance() {
m = mono(8);
assert(text_width("", m) == 0, "empty is 0");
assert(text_width("Stencil", m) == 56, "7 chars at 8 = 56, got {text_width(\"Stencil\", m)}");
}
fn test_fit_leaves_a_string_that_already_fits() {
m = mono(8);
s = fit_text("Stencil", 100, m);
assert(s == "Stencil", "a fitting label must come back untouched, got '{s}'");
}
fn test_fit_truncates_with_a_marker() {
m = mono(8);
s = fit_text("thick_curved", 48, m); // 6 chars of room
assert(s == "thic..", "want 'thic..', got '{s}'");
assert(text_width(s, m) <= 48, "the truncation must itself fit");
}
// ⚠ THE SAME BOX, THE SAME CHARACTER COUNT, A LABEL THAT IS NOT ASCII.
//
// `fit_text` computes its cut in CHARACTERS — `px / advance` — and loft's slice
// bounds are BYTE offsets, so a two-byte character spends two of them. The cut
// then lands early and the label comes back SHORTER than it had room for: not
// corrupt (loft snaps a byte cut outward to a character boundary), just quietly
// wrong, and invisible to a suite whose every fixture is ASCII.
//
// 'thíck_cürved' is 12 characters and 14 bytes. At advance 8 a 48px box has
// room for 6, so 4 survive the marker: 'thíc..'. Byte-cutting at 4 yields 'thí'
// — three characters — and a panel that had room for a fourth never shows it.
fn test_fit_counts_characters_not_bytes() {
m = mono(8);
s = fit_text("thíck_cürved", 48, m);
assert(s == "thíc..", "want 'thíc..', got '{s}'");
assert(len(s) == 6, "6 characters of room were paid for, {len(s)} came back");
assert(text_width(s, m) <= 48, "the truncation must itself fit");
}
// ⚠ THE MARKER ONLY APPEARS WHEN SOMETHING WAS REMOVED. Otherwise a caller
// cannot tell "this fitted" from "this was never measured", and a panel full of
// ".." would read as a rendering bug rather than as narrow boxes.
fn test_the_marker_means_something_was_cut() {
m = mono(8);
assert(fit_text("Item", 200, m) == "Item", "no marker on a label that fits");
cut = fit_text("thick_curved", 48, m);
assert(cut != "thick_curved", "the long one must actually be cut");
}
fn test_a_box_too_small_for_the_marker_still_fits() {
m = mono(8);
s = fit_text("Stencil", 16, m); // 2 chars of room, marker needs 2
assert(text_width(s, m) <= 16, "'{s}' overflows a 16px box");
s0 = fit_text("Stencil", 0, m);
assert(s0 == "", "no room means no text, got '{s0}'");
}
// ⚠ `fits` AND `fit_text` ARE ONE QUESTION ASKED TWO WAYS, and the test is that
// they agree rather than that each is right on its own. A boolean twin that drifts
// from the function it predicts is worse than no twin: a caller asks `fits` and
// then draws the untruncated string, and the overflow is somewhere else entirely.
//
// ⚠ SWEPT ACROSS THE BOUNDARY, not sampled near it. The interesting pixel is the
// one where the answer changes, and a fixture that happens to sit either side of it
// tests nothing about the edge.
fn test_fits_is_true_exactly_when_nothing_would_be_cut() {
m = mono(8);
seen_yes = 0;
seen_no = 0;
for px in 0..120 {
y = fits("Stencil", px, m);
same = fit_text("Stencil", px, m) == "Stencil";
assert(y == same, "at {px}px `fits` says {y} and `fit_text` "
+ "{if same { \"changes nothing\" } else { \"cuts\" }}");
if y { seen_yes += 1; } else { seen_no += 1; }
}
assert(seen_yes > 0 && seen_no > 0,
"the sweep never crossed the boundary — {seen_yes} fit, {seen_no} did not");
}
fn test_unmeasured_metrics_do_not_mangle_text() {
// advance 0 means nobody measured; truncating everything to nothing would be
// worse than passing it through, and the fixed-pitch check is what catches it.
m = metrics_measured(0.0, 0.0, 0, 12);
assert(fit_text("Stencil", 10, m) == "Stencil", "advance 0 must pass text through");
}
// ── the perturbation: the advance is not baked in ────────────────
//
// ⚠ THE GATE FOR B1.2. Every layout assertion in this package would pass with
// `8` hard-coded, because `8` is what the tests happen to use. Running the SAME
// panel at a different advance is what proves the number travels: at 8 px
// "Stencil" fits a button and at 24 px it cannot.
fn wall_spec(status: text) -> PanelSpec {
PanelSpec {
ps_buttons: [
spec_button("None", "1"), spec_button("Raise", "2"), spec_button("Lower", "3"),
spec_button("Stencil", "4"), spec_button("Item", "5"), spec_button("Wall", "6"),
],
ps_selected_button: 3,
ps_items: [entry("none"), entry("solid"), entry("half"), entry("thick_flat"), entry("thick_curved")],
ps_selected_item: 0,
ps_status: status,
}
}
fn panel_at(advance: integer) -> Panel {
panel_build(wall_spec("q=0 r=0 cy=0"), 1280, 720, mono(advance))
}
fn test_a_wider_font_truncates_what_a_narrow_one_fits() {
narrow = panel_at(8);
wide = panel_at(32);
n3 = narrow.p_toolbar[3] ?? Button {};
w3 = wide.p_toolbar[3] ?? Button {};
assert(n3.btn_label == "Stencil", "at advance 8 the label fits whole, got '{n3.btn_label}'");
// 192px of label room: 7 chars fit at advance 8 (56px) and cannot at 32 (224px).
assert(w3.btn_label != "Stencil",
"at advance 32 'Stencil' needs 224px of 192px, got '{w3.btn_label}'");
assert(text_width(w3.btn_label, mono(32)) <= w3.btn_rect.r_w - LABEL_PAD - HOTKEY_COL,
"the wide label still overflows its button: '{w3.btn_label}'");
}
fn test_the_status_line_is_fitted_to_its_bar() {
// The bar spans the WINDOW — it was 240px against 648px of text until B1.2
// measured it, which is what moved the rectangle.
// The 81-character line the panel was actually being handed — the one that
// overflowed a 240px strip by 2.7x before anything measured it.
long = "q=1 r=2 cy=4 facing 3 o'clock ([/] turns the dial, 12 positions) y= 0.0 FPS 60";
for adv in [8, 16, 24] {
p = panel_build(wall_spec(long), 1280, 720, mono(adv));
got = text_width(p.p_status.ss_text, mono(adv));
room = p.p_status.ss_rect.r_w - LABEL_PAD;
assert(got <= room, "status overflows its bar at advance {adv}: {got} > {room}");
}
}
fn test_every_list_item_is_fitted_to_the_list() {
for adv in [8, 16, 24] {
p = panel_build(wall_spec("ready"), 1280, 720, mono(adv));
for it in p.p_list.lb_items {
assert(text_width(it.en_label, mono(adv)) <= p.p_list.lb_rect.r_w - LABEL_PAD,
"list item '{it.en_label}' overflows at advance {adv}");
}
}
}
// And the negative control for the sweep above: at a big enough advance the
// long names MUST actually be cut, or the loop is passing on labels that were
// never in danger.
fn test_the_list_sweep_is_not_vacuous() {
wide = panel_build(wall_spec("ready"), 1280, 720, mono(24));
cut = 0;
for it in wide.p_list.lb_items {
lb = it.en_label;
// ⚠ `ends_with`, NOT a slice. `len` counts characters and a slice bound is a
// byte offset, so `lb[len(lb) - 2..]` asks the wrong question of any label
// that is not ASCII — the same confusion `fit_text` shipped with.
if lb.ends_with("..") { cut += 1; }
}
assert(cut > 0, "no wall name was truncated at advance 24 — the sweep proves nothing");
}
tests/render.loft
// The draw list — plan 18, `B1.3`.
//
// ⚠ WHAT A PICTURE CANNOT SAY. A screenshot shows that something was drawn; it
// argues badly about WHICH rectangle got which colour, and not at all about the
// order they were emitted in. Those are decisions, and a decision is countable.
// The gate's job is the part that needs GL — that these reach the canvas.
use lavition_ui::*;
fn ui_metrics(advance: integer, line_h: integer) -> Metrics {
run = (advance * 10) as float;
metrics_measured(run, run, 10, line_h)
}
fn demo(selected_button: integer, selected_item: integer) -> Panel {
spec = PanelSpec {
ps_buttons: [
spec_button("None", "1"), spec_button("Raise", "2"), spec_button("Lower", "3"),
spec_button("Stencil", "4"), spec_button("Item", "5"), spec_button("Wall", "6"),
],
ps_selected_button: selected_button,
ps_items: [entry("none"), entry("solid"), entry("half"), entry("thick_flat"), entry("thick_curved")],
ps_selected_item: selected_item,
ps_status: "ready",
};
panel_build(spec, 1280, 720, ui_metrics(8, 8))
}
fn test_every_button_gets_a_rectangle() {
dl = panel_draw_list(demo(-1, -1), theme_default());
th = theme_default();
n = 0;
for d in dl { if d.dr_colour == th.th_button { n += 1; } }
assert(n == 6, "six buttons, {n} button rectangles emitted");
}
// ⚠ THE CLAIM A SCREENSHOT ARGUES BADLY ABOUT.
fn test_the_selected_button_is_drawn_differently() {
th = theme_default();
dl = panel_draw_list(demo(3, -1), th);
sel = 0;
plain = 0;
for d in dl {
if d.dr_colour == th.th_button_sel { sel += 1; }
if d.dr_colour == th.th_button { plain += 1; }
}
assert(sel == 1, "exactly one button should be selected, got {sel}");
assert(plain == 5, "the other five stay plain, got {plain}");
}
// And the control: with nothing selected, NOTHING gets the selected colour.
// Without this the test above passes on a list that always highlights one.
fn test_nothing_selected_highlights_nothing() {
th = theme_default();
dl = panel_draw_list(demo(-1, -1), th);
for d in dl {
assert(d.dr_colour != th.th_button_sel, "a button was highlighted with nothing selected");
assert(d.dr_colour != th.th_item_sel, "a row was highlighted with nothing selected");
}
}
// ⚠ THE ORDER IS THE Z-ORDER. Emitting a background after the things standing on
// it paints them out — a bug a picture shows and a count does not, so it is
// pinned here.
fn test_the_backgrounds_come_first() {
th = theme_default();
dl = panel_draw_list(demo(3, 1), th);
first_button = -1;
last_bg = -1;
i = 0;
for d in dl {
if d.dr_colour == th.th_button || d.dr_colour == th.th_button_sel {
if first_button < 0 { first_button = i; }
}
if d.dr_colour == th.th_panel_bg || d.dr_colour == th.th_list_bg
|| d.dr_colour == th.th_status_bg { last_bg = i; }
i += 1;
}
assert(first_button > last_bg,
"a background at {last_bg} is emitted after the first button at {first_button}");
}
fn test_every_rectangle_has_area() {
for d in panel_draw_list(demo(3, 1), theme_default()) {
assert(d.dr_rect.r_w > 0 && d.dr_rect.r_h > 0,
"a zero-area rectangle at {d.dr_rect.r_x},{d.dr_rect.r_y} draws nothing");
}
}
// Everything except the status bar belongs inside the strip. The status bar
// spans the window on purpose (see `status_rect`).
fn test_nothing_but_the_status_escapes_the_strip() {
th = theme_default();
p = demo(3, 1);
for d in panel_draw_list(p, th) {
// the two BARS span the window on purpose (see status_rect / subject_rect)
if d.dr_colour == th.th_status_bg || d.dr_colour == th.th_subject_bg { continue; }
assert(d.dr_rect.r_x >= 0 && d.dr_rect.r_x + d.dr_rect.r_w <= p.p_rect.r_w,
"a rectangle spills out of the 240px strip: x={d.dr_rect.r_x} w={d.dr_rect.r_w}");
}
}
// ── the selected row, and the case that has no picture ───────────
fn test_the_selected_row_is_highlighted_where_it_sits() {
th = theme_default();
p = demo(-1, 2);
lr = p.p_list.lb_rect;
found = false;
for d in panel_draw_list(p, th) {
if d.dr_colour == th.th_item_sel {
found = true;
assert(d.dr_rect.r_y == lr.r_y + 2 * p.p_list.lb_item_height,
"row 2 highlighted at y={d.dr_rect.r_y}, want {lr.r_y + 2 * p.p_list.lb_item_height}");
}
}
assert(found, "row 2 was selected and nothing was highlighted");
}
// ⚠ A SELECTION SCROLLED OUT OF VIEW MUST NOT BE DRAWN. Unclipped it lands ABOVE
// the list — over the toolbar — which is the failure mode of every hand-rolled
// list and is invisible until someone scrolls.
fn test_a_selection_scrolled_out_of_view_is_not_drawn() {
th = theme_default();
p = demo(-1, 0);
p.p_list.lb_scroll = 10 * p.p_list.lb_item_height; // row 0 is far above the well
for d in panel_draw_list(p, th) {
assert(d.dr_colour != th.th_item_sel,
"a scrolled-away selection was drawn at y={d.dr_rect.r_y}");
}
}
fn test_an_empty_panel_still_draws_its_chrome() {
th = theme_default();
empty = PanelSpec {
ps_buttons: [], ps_selected_button: -1,
ps_items: [], ps_selected_item: -1, ps_status: "",
};
dl = panel_draw_list(panel_build(empty, 1280, 720, ui_metrics(8, 8)), th);
assert(len(dl) >= 3, "a panel with no content still has a strip, a well and a bar");
}
// ── the words ────────────────────────────────────────────────────
fn test_every_button_gets_its_label_and_its_hotkey() {
tl = panel_text_list(demo(3, -1), theme_default());
labels: vector<text> = [];
for t in tl { labels += [t.dt_text]; }
for want in ["None", "Raise", "Lower", "Stencil", "Item", "Wall"] {
found = false;
for l in labels { if l == want { found = true; } }
assert(found, "button label '{want}' is not in the text list");
}
for want in ["1", "2", "3", "4", "5", "6"] {
found = false;
for l in labels { if l == want { found = true; } }
assert(found, "hotkey '{want}' is not in the text list");
}
}
fn test_the_status_line_is_in_the_text_list() {
found = false;
for t in panel_text_list(demo(-1, -1), theme_default()) {
if t.dt_text == "ready" { found = true; }
}
assert(found, "the status text was not emitted");
}
// ⚠ THE CLAIM THAT MAKES `p_metrics` WORTH CARRYING. Every string was fitted to
// its box by `panel_build`; this asserts the PLACED text still fits, measured
// with the panel's own metrics. Fitting at one advance and drawing at another
// would put text outside a box it was just proved to fit -- and the picture
// would show a label spilling over a button with nothing red anywhere.
fn test_no_placed_text_overflows_the_strip() {
for adv in [8, 16, 24] {
spec = PanelSpec {
ps_buttons: [
spec_button("None", "1"), spec_button("Raise", "2"), spec_button("Lower", "3"),
spec_button("Stencil", "4"), spec_button("Item", "5"), spec_button("Wall", "6"),
],
ps_selected_button: 0,
ps_items: [entry("none"), entry("solid"), entry("half"), entry("thick_flat"), entry("thick_curved")],
ps_selected_item: 0,
ps_status: "q=1 r=2 cy=4 facing 3 oclock ([/] turns the dial) y= 0.0 FPS 60",
};
p = panel_build(spec, 1280, 720, ui_metrics(adv, adv));
for t in panel_text_list(p, theme_default()) {
right = t.dt_x + text_width(t.dt_text, p.p_metrics);
limit = if t.dt_y >= p.p_status.ss_rect.r_y { p.p_status.ss_rect.r_w }
else { p.p_rect.r_w };
assert(right <= limit,
"at advance {adv} '{t.dt_text}' ends at {right}, past {limit}");
}
}
}
fn test_the_text_sweep_is_not_vacuous() {
spec = PanelSpec {
ps_buttons: [spec_button("Stencil", "4")],
ps_selected_button: 0,
ps_items: [entry("thick_curved")], ps_selected_item: 0, ps_status: "x",
};
p = panel_build(spec, 1280, 720, ui_metrics(32, 32));
cut = 0;
for t in panel_text_list(p, theme_default()) {
if len(t.dt_text) >= 2 && t.dt_text[len(t.dt_text) - 2..len(t.dt_text)] == ".." { cut += 1; }
}
assert(cut > 0, "nothing was truncated at advance 32 — the sweep proves nothing");
}
fn test_text_is_vertically_inside_its_row() {
p = demo(-1, -1);
lh = p.p_metrics.m_line_h;
for t in panel_text_list(p, theme_default()) {
assert(t.dt_y >= 0, "text placed above the window at y={t.dt_y}");
assert(t.dt_y + lh <= p.p_status.ss_rect.r_y + p.p_status.ss_rect.r_h,
"text at y={t.dt_y} runs past the bottom of the panel");
}
}
// ⚠ Same clipping rule as the highlight. Text floating above the list, over the
// toolbar, is the tell that nobody scrolled one while looking at it.
fn test_rows_scrolled_out_of_view_emit_no_text() {
p = demo(-1, -1);
p.p_list.lb_scroll = 10 * p.p_list.lb_item_height;
for t in panel_text_list(p, theme_default()) {
for item in p.p_list.lb_items {
assert(!(t.dt_text == item.en_label && t.dt_y < p.p_list.lb_rect.r_y),
"a scrolled-away row drew '{t.dt_text}' at y={t.dt_y}");
}
}
}
// ⚠ THE INSTRUMENT THAT WOULD HAVE CAUGHT THE HOTKEY BUG, and the reason it is
// stated over PAIRS rather than over boxes. Every individual string fitted its
// own box; the hotkey and the label simply landed on top of each other, which no
// per-string check can see. Two texts sharing a line must not share pixels.
fn test_no_two_texts_on_a_line_overlap() {
for adv in [8, 12, 16] {
spec = PanelSpec {
ps_buttons: [
spec_button("Look", "drag"), // the exact pair that collided
spec_button("Stencil", "4"),
spec_button("Wall", "6"),
],
ps_selected_button: 0,
ps_items: [entry("none"), entry("thick_curved")], ps_selected_item: 0,
ps_status: "ready",
};
p = panel_build(spec, 1280, 720, ui_metrics(adv, adv));
tl = panel_text_list(p, theme_default());
for a in tl {
for b in tl {
if a.dt_x == b.dt_x && a.dt_y == b.dt_y { continue; } // same entry
if a.dt_y != b.dt_y { continue; } // different lines
a_end = a.dt_x + text_width(a.dt_text, p.p_metrics);
assert(a.dt_x >= b.dt_x || a_end <= b.dt_x,
"at advance {adv} '{a.dt_text}' [{a.dt_x}..{a_end}] overlaps '{b.dt_text}' at {b.dt_x}");
}
}
}
}
// ── the subject line (B1.5) ──────────────────────────────────────
fn subject_panel(subject: text, adv: integer) -> Panel {
spec = PanelSpec {
ps_buttons: [spec_button("Stencil", "4"), spec_button("Wall", "6")],
ps_selected_button: 0,
ps_items: [entry("none")], ps_selected_item: 0,
ps_status: "q=0 r=0 cy=0",
ps_subject: subject,
};
panel_build(spec, 1280, 720, ui_metrics(adv, adv))
}
fn test_the_subject_line_is_drawn_and_its_bar_with_it() {
th = theme_default();
p = subject_panel("world hollow-hill · AUTO · level ON", 8);
bar = false;
for d in panel_draw_list(p, th) { if d.dr_colour == th.th_subject_bg { bar = true; } }
assert(bar, "the subject bar was not emitted");
words = false;
for t in panel_text_list(p, th) {
if t.dt_text == "world hollow-hill · AUTO · level ON" { words = true; }
}
assert(words, "the subject text was not emitted");
}
// ⚠ "NEVER HIDDEN" IS A GEOMETRIC CLAIM, so it gets a geometric test. Nothing
// else the panel draws may occupy a pixel of the subject bar — and a strip drawn
// from y=0, which is what it was before B1.5, covers the left end of the line
// exactly where it starts reading.
fn test_nothing_is_drawn_over_the_subject_bar() {
th = theme_default();
p = subject_panel("world hollow-hill · AUTO", 8);
qr = p.p_subject.ss_rect;
for d in panel_draw_list(p, th) {
if d.dr_colour == th.th_subject_bg { continue; }
assert(d.dr_rect.r_y >= qr.r_y + qr.r_h,
"something is drawn at y={d.dr_rect.r_y}, inside the subject bar");
}
for t in panel_text_list(p, th) {
if t.dt_text == p.p_subject.ss_text { continue; }
assert(t.dt_y >= qr.r_y + qr.r_h,
"'{t.dt_text}' is drawn at y={t.dt_y}, inside the subject bar");
}
}
// §C1's own example, which is 69 characters and about 620px — the length that
// settled the bar spanning the window rather than sitting in the 240px strip.
fn test_c1s_own_example_survives_the_bar() {
line = "world hollow-hill · AUTO · level ON · run armed · roof hidden · 30 Hz";
for adv in [8, 9, 12] {
p = subject_panel(line, adv);
assert(p.p_subject.ss_text == line,
"at advance {adv} the subject was truncated to '{p.p_subject.ss_text}'");
}
}
// And the control: it IS fitted, not merely passed through — a line long enough
// to overflow the window still has to be cut.
fn test_a_subject_too_long_for_the_window_is_still_cut() {
long = "world hollow-hill · AUTO · level ON · run armed · roof hidden · 30 Hz · and a great deal more besides, far past the right-hand edge of any window";
p = subject_panel(long, 24);
assert(p.p_subject.ss_text != long, "an over-long subject was not fitted");
assert(text_width(p.p_subject.ss_text, p.p_metrics) <= p.p_subject.ss_rect.r_w - LABEL_PAD,
"the fitted subject still overflows its bar");
}
// ── availability (B6) ────────────────────────────────────────────
//
// ⚠ §C3: AN UNAVAILABLE ENTRY IS SHOWN GREYED WITH ITS REASON, NOT HIDDEN.
// Hiding it makes the author think the thing is missing; showing it with
// `too wide for frame/2x3` tells them what to change. It is §C2's refusal rule —
// reason, never a blank no — applied to a list instead of to a gesture.
fn mixed_panel(adv: integer) -> Panel {
spec = PanelSpec {
ps_buttons: [spec_button("Stencil", "4")],
ps_selected_button: 0,
ps_items: [
entry("oak-2x3"),
entry_blocked("elm-3x3", "too wide for frame/2x3"),
entry("ash-2x3"),
],
ps_selected_item: -1,
ps_status: "ready",
ps_subject: "world x · AUTO",
};
panel_build(spec, 1280, 720, ui_metrics(adv, adv))
}
fn test_an_unavailable_entry_carries_a_reason() {
p = mixed_panel(8);
blocked = 0;
for it in p.p_list.lb_items {
if !it.en_available {
blocked += 1;
assert(it.en_reason != "", "an unavailable entry with no reason is a blank no");
}
}
assert(blocked == 1, "one entry was blocked, found {blocked}");
}
// ⚠ A MISSING REASON BECOMES A VISIBLE PLACEHOLDER rather than an empty string
// nobody notices. "Unavailable because" is the blank no §C2 forbids.
fn test_a_blocked_entry_without_a_reason_says_so() {
e = entry_blocked("x", "");
assert(!e.en_available, "still unavailable");
assert(e.en_reason == "no reason given", "silently empty: '{e.en_reason}'");
}
// NOT HIDDEN — the row is still in the list, and still hit-testable, so an
// author can click it and be told why rather than wonder where it went.
fn test_an_unavailable_entry_is_still_in_the_list() {
p = mixed_panel(8);
assert(len(p.p_list.lb_items) == 3, "three entries, got {len(p.p_list.lb_items)}");
lr = p.p_list.lb_rect;
hit = panel_hit_test(p, lr.r_x + 4, lr.r_y + 1 * p.p_list.lb_item_height + 2);
ok = false;
if hit is UhListItem { li_idx } { ok = li_idx == 1; }
assert(ok, "the blocked row is not clickable — it has been hidden in effect");
}
fn test_the_reason_reaches_the_row_the_author_reads() {
// Wide enough that nothing truncates: the whole sentence is on the row.
p = mixed_panel(4);
found = false;
for t in panel_text_list(p, theme_default()) {
if t.dt_text == "elm-3x3 — too wide for frame/2x3" { found = true; }
}
assert(found, "the reason never reached the text list");
}
// ⚠ GREYED IS THE OTHER HALF, and a grey row with no words says only that
// something is wrong. Both are asserted, because either alone is a worse UI than
// the pair.
fn test_an_unavailable_row_is_drawn_dimmer_than_an_available_one() {
th = theme_default();
p = mixed_panel(8);
lr = p.p_list.lb_rect;
bright = 0;
dim = 0;
for t in panel_text_list(p, th) {
if t.dt_y < lr.r_y || t.dt_y >= lr.r_y + lr.r_h { continue; } // list rows only
if t.dt_colour == th.th_text { bright += 1; }
if t.dt_colour == th.th_text_dim { dim += 1; }
}
assert(dim == 1, "one row should be dimmed, {dim} were");
assert(bright == 2, "the other two should be bright, {bright} were");
}
// And the control: with everything available, NOTHING is dimmed. Without this
// the test above passes on a renderer that greys every row.
fn test_nothing_is_dimmed_when_everything_is_available() {
th = theme_default();
p = demo(-1, -1); // all entries available
lr = p.p_list.lb_rect;
for t in panel_text_list(p, th) {
if t.dt_y < lr.r_y || t.dt_y >= lr.r_y + lr.r_h { continue; }
assert(t.dt_colour != th.th_text_dim,
"'{t.dt_text}' was dimmed with nothing unavailable");
}
}
tests/verbbar.loft
// M2 — THE VERB BAR, and the claims a screenshot cannot make.
//
// plan 22 `M2`. What a gate can say about this strip is *it reached the canvas*; what
// it cannot say is that no slot leaves the bar, that overflow was reported rather than
// dropped, or that a click lands on the verb under the pointer. Those are facts about
// the LAYOUT, countable here, and only arguable from a picture.
use lavition_ui::*;
// The panel suite's own helper, restated here because a test file is its own scope.
fn m() -> Metrics {
run = (8 * 10) as float;
metrics_measured(run, run, 10, 12)
}
// ⚠ FIFTEEN, WHICH IS THE REAL VOCABULARY. A fixture of three would fit any bar and
// say nothing about the case this widget exists for.
//
// ⛔ **AND THE KEYS ARE REAL NAMES, WHICH THEY WERE NOT AT FIRST.** Every slot carried
// `"K"` — one character — so *the glyph fits its box* passed with nothing to fit, and
// the first real run of the editor drew `ArrowUp` (seven characters, 56px at an 8px
// advance) in a 34px slot. **A fixture easier than the thing it stands for is how a
// layout claim goes green while the layout is wrong**, and this row is the correction.
fn fifteen() -> vector<SpecVerb> {
vs: vector<SpecVerb> = [];
ks = ["ArrowUp", "ArrowDown", "F", "G", "R", "H", "O", "Y", "J", "E", "Q",
"X", "Z", "B", "C"];
for i in 0..15 { vs += [spec_verb("verb{i}", "v{i}", ks[i] ?? "")]; }
vs
}
// ── WHERE IT SITS ───────────────────────────────────────────────────────────
fn test_the_bar_is_one_row_clear_of_the_panel_and_the_status_strip() {
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
assert(b.vb_rect.r_h == verbbar_height(),
"the bar is {b.vb_rect.r_h} tall where one row is {verbbar_height()}");
assert(b.vb_rect.r_x >= PANEL_WIDTH,
"the bar starts at {b.vb_rect.r_x}, inside a panel {PANEL_WIDTH} wide");
// ⚠ THE STATUS STRIP IS ACROSS THE BOTTOM OF THE WINDOW, so the bar must end above
// it. Two things claiming the same pixels is the overlap `panel_draw_list` warns
// about for the subject bar, one edge over — and a picture shows it only if someone
// looks at that corner.
assert(b.vb_rect.r_y + b.vb_rect.r_h <= 660 - STATUS_HEIGHT,
"the bar ends at {b.vb_rect.r_y + b.vb_rect.r_h} and the status strip starts "
+ "at {660 - STATUS_HEIGHT}");
assert(b.vb_rect.r_y >= 0, "the bar starts above the window at {b.vb_rect.r_y}");
}
// ⚠ **A CONSUMER WITH NO SIDE PANEL PASSES 0**, and this package must not decide that
// an editor has one.
fn test_a_consumer_with_no_panel_gets_the_full_width() {
wide = verbbar_build(fifteen(), 1200, 660, 0, m());
narrow = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
assert(wide.vb_rect.r_w > narrow.vb_rect.r_w,
"with no panel the bar is {wide.vb_rect.r_w} wide and with one it is "
+ "{narrow.vb_rect.r_w}");
assert(len(wide.vb_slots) >= len(narrow.vb_slots),
"the wider bar shows {len(wide.vb_slots)} slots and the narrower "
+ "{len(narrow.vb_slots)}");
}
// ── THE SLOTS ───────────────────────────────────────────────────────────────
// ⛔ **THE ROW THAT FOUND THE OFF-BY-ONE.** A closed-form `avail / (W + GAP)` puts the
// last slot half outside the bar at some widths, which draws as a clipped square and
// is invisible in a screenshot at any width but that one.
fn test_no_slot_leaves_the_bar_at_any_window_width() {
for w in [420, 640, 800, 1024, 1200, 1600] {
b = verbbar_build(fifteen(), w, 660, PANEL_WIDTH, m());
for s in b.vb_slots {
assert(s.vt_rect.r_x >= b.vb_rect.r_x,
"at {w}px a slot starts at {s.vt_rect.r_x}, left of the bar at "
+ "{b.vb_rect.r_x}");
assert(s.vt_rect.r_x + s.vt_rect.r_w <= b.vb_rect.r_x + b.vb_rect.r_w,
"at {w}px a slot ends at {s.vt_rect.r_x + s.vt_rect.r_w}, past the bar "
+ "at {b.vb_rect.r_x + b.vb_rect.r_w}");
assert(s.vt_rect.r_y >= b.vb_rect.r_y
&& s.vt_rect.r_y + s.vt_rect.r_h <= b.vb_rect.r_y + b.vb_rect.r_h,
"at {w}px a slot is outside the bar vertically");
}
}
}
fn test_slots_do_not_overlap_and_keep_their_order() {
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
assert(len(b.vb_slots) > 1, "one slot cannot show an ordering");
for i in 0..len(b.vb_slots) - 1 {
l = b.vb_slots[i] ?? VerbSlot {};
r = b.vb_slots[i + 1] ?? VerbSlot {};
assert(l.vt_rect.r_x + l.vt_rect.r_w <= r.vt_rect.r_x,
"slot {i} ends at {l.vt_rect.r_x + l.vt_rect.r_w} and slot {i + 1} starts "
+ "at {r.vt_rect.r_x}");
assert(l.vt_verb == "verb{i}",
"slot {i} names '{l.vt_verb}' — the order the caller gave is the order it "
+ "is drawn in");
}
}
// ── OVERFLOW ────────────────────────────────────────────────────────────────
// ⚠ **REPORTED, NEVER SILENTLY DROPPED.** A bar that truncated quietly would read as
// *those verbs do not exist here*, which is the wrong sentence for an author who
// cannot find one.
fn test_what_does_not_fit_is_counted_rather_than_dropped() {
b = verbbar_build(fifteen(), 560, 660, PANEL_WIDTH, m());
assert(b.vb_hidden > 0,
"a 560px window fits all 15 slots — this row is asking nothing");
assert(len(b.vb_slots) + b.vb_hidden == 15,
"{len(b.vb_slots)} shown and {b.vb_hidden} hidden of 15 — one went missing");
// The control: given room, nothing is hidden and the count is honest in both
// directions.
wide = verbbar_build(fifteen(), 1600, 660, PANEL_WIDTH, m());
assert(wide.vb_hidden == 0 && len(wide.vb_slots) == 15,
"with room the bar shows {len(wide.vb_slots)} and hides {wide.vb_hidden}");
}
// ⚠ **AND THE BAR NEVER GROWS A SECOND ROW**, which is the whole of *doesn't take up
// too much screen space*. A wrapping bar eats the window it is drawn over.
fn test_the_bar_is_the_same_height_however_many_verbs_there_are() {
few = verbbar_build([spec_verb("a", "a", "A")], 1200, 660, PANEL_WIDTH, m());
many: vector<SpecVerb> = [];
for i in 0..60 { many += [spec_verb("verb{i}", "v{i}", "K")]; }
lots = verbbar_build(many, 1200, 660, PANEL_WIDTH, m());
assert(few.vb_rect.r_h == lots.vb_rect.r_h,
"1 verb makes a bar {few.vb_rect.r_h} tall and 60 make one "
+ "{lots.vb_rect.r_h} tall");
assert(lots.vb_hidden > 0, "60 verbs fitted a 1200px window — nothing overflowed");
// ⚠ ONE SLOT, SO THE REFERENCE IS A FACT AND NOT A FALLBACK. This read carried
// a `?? 0 - 1` whose default loft proved could never fire: `few` is built from
// exactly one spec four lines up. A sentinel that cannot appear reads as care
// and asserts nothing, and it would have stood in silently for an empty bar.
base_y = few.vb_slots[0].vt_rect.r_y;
for s in lots.vb_slots {
assert(s.vt_rect.r_y == base_y,
"a slot sits at y {s.vt_rect.r_y} where the single-slot bar puts one at "
+ "{base_y} — the bar has wrapped");
}
}
// ── HITTING IT ──────────────────────────────────────────────────────────────
fn test_a_click_in_a_slot_names_that_slots_verb() {
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
i = 0;
for s in b.vb_slots {
cx = s.vt_rect.r_x + s.vt_rect.r_w / 2 ?? 0;
cy = s.vt_rect.r_y + s.vt_rect.r_h / 2 ?? 0;
assert(verbbar_hit(b, cx, cy) == i,
"the centre of slot {i} hits {verbbar_hit(b, cx, cy)}");
assert(verbbar_verb(b, verbbar_hit(b, cx, cy)) == s.vt_verb,
"slot {i} names '{s.vt_verb}' and the hit reads "
+ "'{verbbar_verb(b, verbbar_hit(b, cx, cy))}'");
i += 1;
}
}
// ⚠ **THE MISSES MATTER AS MUCH AS THE HITS**, and the gap between slots is the one a
// hit-test written as *which slot index is this x nearest* gets wrong: it answers a
// slot for a click that landed on the bar's background.
fn test_a_click_between_or_outside_slots_names_nothing() {
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
s0 = b.vb_slots[0] ?? VerbSlot {};
gap_x = s0.vt_rect.r_x + s0.vt_rect.r_w + 1;
gap_y = s0.vt_rect.r_y + s0.vt_rect.r_h / 2 ?? 0;
assert(verbbar_hit(b, gap_x, gap_y) == 0 - 1,
"a click in the gap between slots hit {verbbar_hit(b, gap_x, gap_y)}");
assert(verbbar_hit(b, 0, 0) == 0 - 1, "a click at the origin hit a slot");
assert(verbbar_hit(b, 5000, 5000) == 0 - 1, "a click off the window hit a slot");
assert(verbbar_verb(b, 0 - 1) == "", "a miss named '{verbbar_verb(b, 0 - 1)}'");
assert(verbbar_verb(b, 999) == "", "an index past the end named a verb");
}
// ── THE PICTURE ─────────────────────────────────────────────────────────────
fn test_every_slot_is_drawn_and_the_bar_is_behind_them() {
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
dl = verbbar_draw_list(b, theme_default());
assert(len(dl) == len(b.vb_slots) + 1,
"{len(dl)} rectangles for {len(b.vb_slots)} slots and one bar");
// ⚠ THE ORDER IS THE Z-ORDER — `panel_draw_list`'s own rule. A bar emitted after its
// slots paints over every one of them, which is a bug a picture shows and a count
// does not.
assert((dl[0] ?? DrawRect {}).dr_rect.r_w == b.vb_rect.r_w,
"the first rectangle is not the bar — the slots would be painted over");
}
// ⚠ **AN ARMED SLOT IS DRAWN DIFFERENTLY, AND THAT IS A FACT ABOUT THE DECISION** —
// countable here, arguable only from a screenshot otherwise. A `run` with one end down
// is the case: the next press closes it, and an author needs to see that.
fn test_an_armed_slot_is_drawn_differently_from_a_resting_one() {
th = theme_default();
rest = verbbar_draw_list(verbbar_build([spec_verb("r", "run", "R")],
1200, 660, PANEL_WIDTH, m()), th);
armed = verbbar_draw_list(verbbar_build([spec_verb_on("r", "run", "R", true)],
1200, 660, PANEL_WIDTH, m()), th);
assert((rest[1] ?? DrawRect {}).dr_colour != (armed[1] ?? DrawRect {}).dr_colour,
"an armed slot and a resting one are both colour "
+ "{(rest[1] ?? DrawRect {}).dr_colour}");
}
// ⚠ **THE KEY IS THE TOP LINE AND THE LABEL READS UNDER IT.** The question a bar
// answers at a glance is *which key does this*; putting the label first makes it a
// list of features with the key as an annotation, which is the toolbar it replaces.
fn test_each_slot_draws_its_key_over_its_label_inside_its_own_box() {
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, m());
tl = verbbar_text_list(b, theme_default(), m());
assert(len(tl) == len(b.vb_slots) * 2,
"{len(tl)} strings for {len(b.vb_slots)} slots of two lines each");
i = 0;
for s in b.vb_slots {
k = tl[i * 2] ?? DrawText {};
l = tl[i * 2 + 1] ?? DrawText {};
assert(k.dt_text == s.vt_key && l.dt_text == s.vt_label,
"slot {i} draws '{k.dt_text}' over '{l.dt_text}' where the slot is "
+ "'{s.vt_key}' / '{s.vt_label}'");
assert(k.dt_y < l.dt_y,
"slot {i} draws its key at y {k.dt_y} and its label at {l.dt_y} — the key "
+ "is the answer and belongs on top");
assert(k.dt_x >= s.vt_rect.r_x
&& k.dt_x + text_width(k.dt_text, m()) <= s.vt_rect.r_x + s.vt_rect.r_w,
"slot {i}'s key runs outside its own box");
assert(l.dt_y + text_height(m()) <= s.vt_rect.r_y + s.vt_rect.r_h,
"slot {i}'s label runs below its own box");
i += 1;
}
}
// ⚠ **AN UNBOUND VERB KEEPS ITS SLOT AND DRAWS A BLANK KEY.** After a rebind displaces
// something, the slot says *this exists and you have no key for it* — the only honest
// reading, and the reason `keymap_bind` leaves a displaced verb in the list at all.
fn test_a_verb_with_no_key_still_gets_a_slot() {
b = verbbar_build([spec_verb("cellar", "flr-", "")], 1200, 660, PANEL_WIDTH, m());
assert(len(b.vb_slots) == 1, "an unbound verb was dropped from the bar");
tl = verbbar_text_list(b, theme_default(), m());
assert((tl[0] ?? DrawText {}).dt_text == "",
"the key line reads '{(tl[0] ?? DrawText {}).dt_text}' for an unbound verb");
assert((tl[1] ?? DrawText {}).dt_text == "flr-",
"the label went missing with the key");
}
// ⚠ **AND THE OVERFLOW COUNT IS DRAWN**, which is what makes `vb_hidden` a report
// rather than a field nobody reads.
fn test_the_hidden_count_reaches_the_picture() {
b = verbbar_build(fifteen(), 560, 660, PANEL_WIDTH, m());
assert(b.vb_hidden > 0, "nothing overflowed — this row is asking nothing");
tl = verbbar_text_list(b, theme_default(), m());
assert(len(tl) == len(b.vb_slots) * 2 + 1,
"{len(tl)} strings for {len(b.vb_slots)} slots and one overflow mark");
last = tl[len(tl) - 1] ?? DrawText {};
assert(last.dt_text == "+{b.vb_hidden}",
"the overflow mark reads '{last.dt_text}' where {b.vb_hidden} are hidden");
}
// ⛔ **THE ROW THE SLOT WIDTH WAS SHIPPED WITHOUT.** Containment is satisfied by
// truncation — `f…` is inside its box — so *the text fits* was green while every label
// in the real editor was cut to one character. The claim that matters is that the slot
// holds what it was designed to hold, **at the metrics the consumer actually has**:
// the editor's font measures 10px per character, mono, which its own `client: panel …
// advance 10` line reports.
fn test_a_four_character_label_survives_the_editors_own_metrics() {
run = (10 * 10) as float;
editor = metrics_measured(run, run, 10, 14);
b = verbbar_build([spec_verb("stair_up", "st+", "E"),
spec_verb("fence", "fenc", "F"),
spec_verb("opening", "door", "O")], 1200, 660, PANEL_WIDTH, editor);
want = ["st+", "fenc", "door"];
i = 0;
for s in b.vb_slots {
assert(s.vt_label == want[i] ?? "",
"slot {i} draws '{s.vt_label}' where the caller gave '{want[i] ?? \"\"}' — "
+ "a {VERB_SLOT_W}px slot cannot hold 4 characters at a 10px advance");
i += 1;
}
// …and the key glyphs too, which is the same claim for the top line: `Spc` and `Esc`
// are three characters and `key_glyph` promises they arrive intact.
k = verbbar_build([spec_verb("x", "x", "Spc")], 1200, 660, PANEL_WIDTH, editor);
assert((k.vb_slots[0] ?? VerbSlot {}).vt_key == "Spc",
"a 3-character key glyph draws as "
+ "'{(k.vb_slots[0] ?? VerbSlot {}).vt_key}'");
}
// ⛔ **THE SECOND ROW THIS WIDGET SHIPPED WITHOUT.** `k.dt_y < l.dt_y` asserts the key
// is ABOVE the label; it says nothing about whether they touch. The first real run drew
// `Up` through `rise` and `Dn` through `sink` — two legible strings in the same pixels
// — with the suite green. ⚠ At the EDITOR's metrics: `UI_SIZE` 16.0 measures about 16px
// a line, and a fixture at 12 has four pixels of slack that hides the defect.
fn test_the_two_lines_of_a_slot_do_not_overlap_at_the_editors_metrics() {
run = (10 * 10) as float;
editor = metrics_measured(run, run, 10, 16);
b = verbbar_build(fifteen(), 1200, 660, PANEL_WIDTH, editor);
tl = verbbar_text_list(b, theme_default(), editor);
i = 0;
for s in b.vb_slots {
k = tl[i * 2] ?? DrawText {};
l = tl[i * 2 + 1] ?? DrawText {};
assert(k.dt_y + text_height(editor) <= l.dt_y,
"slot {i}: the key runs to y {k.dt_y + text_height(editor)} and the label "
+ "starts at {l.dt_y} — they are drawn through each other");
assert(l.dt_y + text_height(editor) <= s.vt_rect.r_y + s.vt_rect.r_h,
"slot {i}: the label runs to y {l.dt_y + text_height(editor)} and its slot "
+ "ends at {s.vt_rect.r_y + s.vt_rect.r_h}");
assert(k.dt_y >= s.vt_rect.r_y,
"slot {i}: the key starts at y {k.dt_y}, above its slot at {s.vt_rect.r_y}");
i += 1;
}
}
tests/widgets.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
use lavition_ui::*;
// ── §C3 — one widget, two families (plan 18 `B5.1`) ─────────────────────────
//
// ⚠ THE KIND IS ON THE ENTRY, and that is what stops a second list existing. A
// material and a part are each *a named thing you pick and then place*, so they
// share the widget; what tells them apart travels WITH the row rather than in a
// parallel structure the consumer has to keep in step.
fn test_an_entry_carries_the_family_it_belongs_to() {
m = entry_of("material", "grass");
p = entry_of("part", "house/cottage");
assert(m.en_kind == "material" && p.en_kind == "part", "the kind did not travel");
assert(m.en_available && p.en_available, "a kind must not change availability");
b = entry_blocked_of("part", "door/oak", "too wide");
assert(b.en_kind == "part" && !b.en_available && b.en_reason == "too wide",
"a blocked entry lost its kind or its reason");
}
// ⚠ AND THE OLD CONSTRUCTORS STILL MEAN WHAT THEY MEANT. `entry`/`entry_blocked`
// are what every existing consumer calls; a kind that defaulted to "material"
// would quietly label whatever a caller had not thought about.
fn test_an_unlabelled_entry_claims_no_family() {
assert(entry("grass").en_kind == KIND_UNSET, "entry() invented a kind");
assert(entry_blocked("tree", "scattered").en_kind == KIND_UNSET,
"entry_blocked() invented a kind");
assert(entry_blocked("x", "").en_reason != "", "the blank-no placeholder was lost");
}