lavition_ui source

← 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.

ItemDefinedWorked exampleCall sites
Metrics struct21—43 61 63 65 81 85 … (17 total)
metrics_advance fn43—126 27 46
metrics_measured fn60—15 14 16 25 37 44 … (11 total)
text_width fn81—87 123 224 232 213 53 … (17 total)
text_height fn85—222 296 297 299 300
fits fn87—94 120 122 166 107
fit_text fn121—150 157 182 203 207 156 … (18 total)
hotkey_room fn44—157 212 224 226
SpecButton struct50—62 71 72
PanelSpec struct61—141 29 184 19 20 175 … (15 total)
spec_button fn71—31 32 22 23 147 148 … (16 total)
Panel struct76—142 190 242 250 63 130 … (12 total)
subject_rect fn94—188 62 71
panel_rect fn101—192 52
toolbar_button_rect fn108—146 54 72 78 79 80 … (7 total)
list_rect fn117—163 95 96 104
status_rect fn130—187 72 110 114
panel_build fn141—39 188 120 125 133 152 … (21 total)
UiHit enum220—250 18 23
list_row_rect fn242—130 175
panel_hit_test fn250—45 55 73 83 97 106 … (11 total)
DrawRect struct21—63 64 67 68 69 74 … (16 total)
Theme struct28—42 43 63 130 193 214
theme_default fn42—29 30 38 53 65 83 … (28 total)
panel_draw_list fn63—29 39 54 66 83 94 … (11 total)
DrawText struct118—130 131 138 140 156 165 … (20 total)
panel_text_list fn130—145 162 186 204 213 225 … (12 total)
verbbar_height fn76—137 35 36
SpecVerb struct84—91 92 95 96 133 23 … (8 total)
spec_verb fn91—27 120 122 192 232 263 … (9 total)
spec_verb_on fn95—194
VerbSlot struct100—110 147 153 186 88 89 … (9 total)
VerbBar struct108—134 165 174 184 193 214
verbbar_build fn133—34 52 53 69 85 105 … (20 total)
verbbar_hit fn174—148 149 150 152 165 166 … (8 total)
verbbar_verb fn184—150 152 169 170
verbbar_draw_list fn193—177 192 194
verbbar_text_list fn214—206 234 246 291
UiRect struct10—77 94 101 108 117 130 … (16 total)
rect fn17—95 105 110 122 131 243 … (13 total)
rect_contains fn21—256 260 177 140 37 42 … (10 total)
Button struct27—78 143 147 126 127 164 … (7 total)
KIND_UNSET const51—61 72 168 25 26
Entry struct53—64 164 181 60 61 64 … (11 total)
entry fn60—35 26 166 168 151 21 … (17 total)
entry_of fn64—149 150 12 13
entry_blocked fn71—343 368 26 27 28
entry_blocked_of fn75—72 151 16
ListBox struct82—79 194
StatusStrip struct91—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");
}