Skip to main content

kui_lua/
lib.rs

1//! Lua extensions for kui. A script defines `view(env, slot)` returning a plain
2//! table tree (built with the injected `row`/`column`/`text`/`button`
3//! prelude) and optionally `on_event(ev)`, whose return value is its replies
4//! to the host; `slot` says which slot the host is filling (`slot.name`,
5//! `slot.namespace`, `slot.params`, `slot.key`; ADR 0014), and a `slots`
6//! global lists the names it fills. `env` carries host facts
7//! (refresh rate, focus, viewport), queries (`env.edit_text(key)`,
8//! `env.is_focused(key)`, `env.is_hovered(key)`, `env.is_pressed(key)`,
9//! `env.measure_text(s, opts, max_w)`), focus verbs (`env.set_focus(key)`,
10//! `env.blur()`, `env.focus_next()`, `env.focus_prev()`),
11//! `env.announce(text, politeness)` and scroll calls
12//! (`env.reveal(key)`, `env.scroll_offset(key)`, `env.set_scroll(key, x, y)`,
13//! `env.shift_scroll(key, drawn, target)`, `env.scroll_geometry(key)`), file dialogs
14//! (`env.request_files(opts)`, `env.awaiting_files()`), text queries (`env.text_hit(key, x, y)`,
15//! `env.caret_rect(key, byte)`) and window requests
16//! (`env.set_window_size(window, w, h)`, `env.focus_window(window)`); the
17//! root table may set `window_title`, `always_on_top`, `secure_input` and `option_as_alt`. Because the IR is data all the way down, the binding is
18//! just table-to-node conversion — no closures cross the boundary. One
19//! global is native rather than the prelude's: `row_heights(rows,
20//! estimate)`, the core's `RowHeights` as userdata the script keeps, which
21//! the prelude's `list` slices a variable-height list by (backlog C46).
22//!
23//! ## The two `focus` names
24//!
25//! `env.focused` and `env.focus` are one letter apart and are *not* the
26//! same fact, so neither is going away:
27//!
28//! - `env.focused` (bool) is the **window**'s keyboard focus — whether this
29//!   window has the keyboard at all. It is `Env::focused`, and every
30//!   binding carries it; Node reads it off its own `env` object.
31//! - `env.focus` (integer, nil for none) is the focused **node**'s key.
32//!   Node spells this `ctx.focused()` and C `kui_focused`.
33//!
34//! Lua puts host facts and runtime queries on one table where Node has two
35//! objects (`env` and `ctx`), so the name `focused` was already spent on
36//! the window fact and the node reading could not have it. That also
37//! settles the verbs: `env.focus` is a value, so moving focus is
38//! `env.set_focus(key)` rather than the `focus(key)` of the other
39//! bindings. `env.blur()`, `env.focus_next()` and `env.focus_prev()` need
40//! no such dodge and keep the Node and C spellings.
41//!
42//! `env.set_focus`, `env.is_focused` and `env.reveal` take the node either
43//! way it can be spelled: the integer key an event carried, or the string
44//! its `key` field declared — `env.set_focus("note")` — resolved through
45//! the frame being built so far and then the last finished one
46//! (`Ui::key_of`), so a node no event has come from can be named — among
47//! the script's own nodes: a script is a guest in the host's frame, and a
48//! label the host or another plugin declared is not one it can see. Two
49//! nodes of its own on one label under different parents resolve to the
50//! first in tree order with an `ambiguous-key` warning; a label no node
51//! declared is an error naming both spellings.
52//!
53//! `set_focus` and `blur` take effect at once; `focus_next` / `focus_prev`
54//! resolve when the frame finishes, because the Tab ring is made of a
55//! finished tree and `view` is still declaring one (`Ui::focus_next`).
56//! Either way `env.focus` is a value the host wrote before `view` ran, so
57//! it still reads the focus the frame opened with — `env.is_focused(key)`
58//! is the query that answers about now.
59//!
60//! Props come from the shared schema (`kui_core::schema`): every row is
61//! reachable from Lua under its snake_case name (`min_width`, `on_click`,
62//! `line_height`, ...), so Lua and the Node binding accept the same surface
63//! by construction. Only the composites keep Lua-flavored shapes:
64//! `pad = 8 | {all,x,y,l,r,t,b}`, `border = {w, color}`, `scroll`/
65//! `scroll_x`/`scroll_y`/`clip` booleans, `float = "below" | {anchor, at,
66//! self, dx, dy, fit}` (`self_at` still answers to `self`), sizing
67//! `{pct = 50} | {grow = 2}`, and `tooltip = "hint"` on a container
68//! (hover-gated). Those are *shapes*, not rules: what a name falls back
69//! to, what a preset attaches to and what a hint implies are decided in
70//! `kui_core::spec`, which this module hands its extracted scalars to.
71//!
72//! ## A script may host a plugin of its own
73//!
74//! `env.add_extension(namespace, path)` loads a C extension — a `.so` /
75//! `.dylib` / `.dll` exporting the `kui_ext_*` entry points
76//! `crates/kui-ffi/include/kui.h` describes, the same plugin a Rust, C or
77//! Node host loads — and `fill { name = "ns/slot", params = ... }` is the
78//! position it draws in, among the script's own children. The script is
79//! a host to it exactly as its host is a host to the script: it declares
80//! where, it passes params every frame, and the plugin's replies come
81//! back to `on_event` with `from` naming the namespace
82//! (`kui_core::slot`). It is C libraries and only that; a script does not
83//! load another script, because a host that wants two scripts loads two.
84//!
85//! Events arrive as their payload table plus `node_key` (the emitting
86//! node's key as an integer), which is what `env.edit_text` takes.
87
88use kui_core::schema::{self, Kind, Parsed, PropsOut};
89use kui_core::{
90    Align, Color, Content, EditOptions, Extension, FloatConfig, Key, PadShorthand, Sizing, Slot,
91    Span, Ui, UiEvent, Value, WindowConfig, widgets,
92};
93use mlua::{Lua, Table};
94
95mod meta;
96mod rows;
97pub use meta::luals_meta;
98
99const PRELUDE: &str = include_str!("prelude.lua");
100
101pub struct LuaExtension {
102    lua: Lua,
103    name: String,
104    /// The script's `slots` global, read once at load: the slot names it
105    /// fills (ADR 0014 decision 2). Empty — no global — means `"root"`;
106    /// `{ "*" }` means every name the host declares under the namespace,
107    /// for a script that registers its views after it loads (backlog K1).
108    slots: Vec<String>,
109    /// The C extensions this script loaded (`env.add_extension`), in the
110    /// order it asked for them. A `RefCell` because the loading happens
111    /// inside `view`, where the script's own interpreter holds a shared
112    /// borrow of everything else here.
113    loaded: std::cell::RefCell<Vec<Loaded>>,
114    /// The full name of every slot this script has filled, by the slot's
115    /// key — what `on_event` reads `ev.slot` off, since an event carries
116    /// the key and the script thinks in the names it was handed
117    /// (backlog K2).
118    slot_names: std::collections::HashMap<kui_core::Key, String>,
119    /// The `tokens = { colors = …, lengths = … }` global the script
120    /// declared at load (ADR 0027), declared into the core under this
121    /// extension's origin on the first `view` that finds none there;
122    /// `env.set_tokens` replaces it from inside a view.
123    tokens: Option<kui_core::Tokens>,
124}
125
126/// One plugin a script loaded: the namespace it chose, where it came
127/// from, and the origin the frame's list gave it.
128struct Loaded {
129    namespace: String,
130    path: std::path::PathBuf,
131    origin: kui_core::OriginId,
132}
133
134impl LuaExtension {
135    pub fn from_source(name: impl Into<String>, source: &str) -> mlua::Result<Self> {
136        let lua = Lua::new();
137        rows::register(&lua)?;
138        lua.load(PRELUDE).set_name("kui:prelude").exec()?;
139        let name = name.into();
140        lua.load(source).set_name(&name).exec()?;
141        let slots = match lua.globals().get::<mlua::Value>("slots")? {
142            mlua::Value::Nil => Vec::new(),
143            mlua::Value::Table(t) => t.sequence_values::<String>().collect::<mlua::Result<_>>()?,
144            other => {
145                return Err(mlua::Error::runtime(format!(
146                    "`slots` must be a list of slot names, not {}",
147                    other.type_name()
148                )));
149            }
150        };
151        let tokens = match lua.globals().get::<mlua::Value>("tokens")? {
152            mlua::Value::Nil => None,
153            mlua::Value::Table(t) => Some(parse_tokens(&t)?),
154            other => {
155                return Err(mlua::Error::runtime(format!(
156                    "`tokens` must be a table {{ colors = …, lengths = … }}, not {}",
157                    other.type_name()
158                )));
159            }
160        };
161        Ok(Self {
162            lua,
163            name,
164            slots,
165            loaded: Default::default(),
166            slot_names: Default::default(),
167            tokens,
168        })
169    }
170
171    /// The namespace a reply's origin names, for a plugin this script
172    /// loaded: what `on_event` puts on the event as `from`.
173    fn loaded_as(&self, origin: kui_core::OriginId) -> Option<String> {
174        self.loaded
175            .borrow()
176            .iter()
177            .find(|l| l.origin == origin)
178            .map(|l| l.namespace.clone())
179    }
180
181    /// The script's own interpreter. A host reaches for this to seed a
182    /// global the script reads — the escape hatch for facts that are not
183    /// `env` and not events, which is what the conformance corpus needs to
184    /// tell a script which phase of a scene to build.
185    pub fn lua(&self) -> &Lua {
186        &self.lua
187    }
188
189    pub fn from_file(path: impl AsRef<std::path::Path>) -> mlua::Result<Self> {
190        let path = path.as_ref();
191        let source = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
192        let name = path
193            .file_name()
194            .map_or_else(|| "lua".into(), |n| n.to_string_lossy().into_owned());
195        Self::from_source(name, &source)
196    }
197}
198
199impl Extension for LuaExtension {
200    fn name(&self) -> &str {
201        &self.name
202    }
203
204    fn slots(&self) -> &[String] {
205        &self.slots
206    }
207
208    fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
209        let view: mlua::Function = self
210            .lua
211            .globals()
212            .get("view")
213            .map_err(|_| "script defines no view()".to_string())?;
214        // Which slot this is, as `view`'s second argument rather than a
215        // field of `env`: `env`'s value keys are pinned to
216        // `schema::ENV_FIELDS` across every binding, and a slot is the
217        // host's fact, not the driver's. A script written as `view(env)`
218        // never sees it.
219        let slot_table = slot_table(&self.lua, slot).map_err(|e| format!("slot: {e}"))?;
220        self.slot_names
221            .entry(slot.key)
222            .or_insert_with(|| slot.full_name());
223        // The table the script was loaded with, declared once per core
224        // under this origin — a script that declares from `view` through
225        // `env.set_tokens` has already, and is not overwritten.
226        let origin = ui.core().origin();
227        if let Some(t) = &self.tokens
228            && !ui.core().tokens_declared(origin)
229        {
230            ui.set_tokens(t.clone());
231        }
232        // The env's query functions borrow the frame for the duration of
233        // view(); the returned table outlives the scope, the borrow does
234        // not. A RefCell because measurement shapes text (a mutable query)
235        // while the rest only read; Lua calls them one at a time.
236        let root: Table = {
237            let frame = std::cell::RefCell::new(&mut *ui);
238            self.lua
239                .scope(|scope| {
240                    let env = env_table(&self.lua, scope, &frame, &self.loaded)?;
241                    view.call((env, slot_table))
242                })
243                .map_err(|e| format!("view(): {e}"))?
244        };
245        // The root table may declare host state alongside the tree.
246        if let Ok(Some(title)) = root.get::<Option<String>>("window_title") {
247            ui.window_title(&title);
248        }
249        if let Ok(Some(true)) = root.get::<Option<bool>>("always_on_top") {
250            ui.always_on_top(true);
251        }
252        // Secure keyboard entry, the same shape (backlog F85): a script at
253        // a password prompt declares it on every view the prompt is up.
254        if let Ok(Some(true)) = root.get::<Option<bool>>("secure_input") {
255            ui.secure_input(true);
256        }
257        // Which Option keys are Alt on macOS (backlog F113), by name; a
258        // name kui does not have is the script's mistake, said as one.
259        // `"none"` declares nothing, as a root that leaves it out and as
260        // Node's `optionAsAlt: 'none'` (`configure_root_from`): a script
261        // writing its setting through does not take back the side a host
262        // or another slot declared this frame (backlog RG84).
263        if let Some(name) = root
264            .get::<Option<String>>("option_as_alt")
265            .map_err(|e| format!("option_as_alt: {e}"))?
266        {
267            let v = kui_core::OptionAsAlt::from_name(&name).ok_or_else(|| {
268                format!(
269                    "option_as_alt: expected \"none\", \"left\", \"right\" or \"both\", got {name:?}"
270                )
271            })?;
272            if v != kui_core::OptionAsAlt::None {
273                ui.option_as_alt(v);
274            }
275        }
276        declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
277        build_node(ui, &root).map_err(|e| format!("view table: {e}"))
278    }
279
280    fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
281        let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
282            return Vec::new();
283        };
284        let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
285            // Map payloads learn which node emitted them; edit widgets emit
286            // {kind="changed"|"submit"} and scripts read the text back with
287            // env.edit_text(ev.node_key).
288            if let mlua::Value::Table(t) = &p
289                && !t.contains_key("node_key")?
290            {
291                t.set("node_key", ev.key.0 as i64)?;
292                // And which window it came from — the number `env.window.id`
293                // reads in that window's view, as Node's `ev.window` and
294                // C's `KuiEvent.window` carry (AR26: a panel drawn into
295                // two windows could not tell which one clicked).
296                t.set("window", ev.window.0)?;
297                // And the slot the node was filled into, by its full name
298                // — what the script declared with `fill { name = … }` —
299                // so a script filling one slot per pane routes by pane
300                // without stamping every payload (backlog K2). Absent for
301                // a node outside any fill.
302                if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
303                    t.set("slot", name.as_str())?;
304                }
305                // And, when this is a reply from a plugin the script
306                // loaded, who is answering: the namespace it chose in
307                // `env.add_extension`. Absent for the script's own nodes,
308                // which is what tells the two apart. `node_key` on a reply
309                // is the key of the node *inside the plugin* whose event it
310                // answers, which is the plugin's business and not this
311                // script's — `from` is the useful half.
312                if let Some(ns) = self.loaded_as(ev.origin) {
313                    t.set("from", ns)?;
314                }
315            }
316            Ok(p)
317        });
318        // What `on_event` returns is the script's replies to the host (ADR
319        // 0014 decision 6): nothing, a table (one reply), or a sequence of
320        // tables (several) — the list-or-map reading `lua_to_value` already
321        // makes.
322        match payload.and_then(|p| f.call::<mlua::Value>(p)) {
323            Ok(mlua::Value::Nil) => Vec::new(),
324            Ok(v) => match lua_to_value(&v) {
325                Ok(Value::List(replies)) => replies,
326                Ok(reply) => vec![reply],
327                Err(e) => {
328                    eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
329                    Vec::new()
330                }
331            },
332            Err(e) => {
333                eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
334                Vec::new()
335            }
336        }
337    }
338}
339
340/// `view`'s second argument: `{ name = ..., namespace = ..., params = ...,
341/// key = ... }` — the slot in the script's own vocabulary, the namespace
342/// the host loaded the script under (what tells one instance from
343/// another), `params` absent for a slot declared without any
344/// (`Value::Null`), and the slot's key as the integer the events and
345/// `env.set_focus` use.
346fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
347    let t = lua.create_table()?;
348    t.set("name", slot.name)?;
349    t.set("namespace", slot.namespace)?;
350    t.set("key", slot.key.0 as i64)?;
351    if !matches!(slot.params, Value::Null) {
352        t.set("params", value_to_lua(lua, slot.params)?)?;
353    }
354    Ok(t)
355}
356
357/// A node named either way a script can: the integer key an event carried,
358/// or the string label its `key` field declared, resolved through the
359/// frame so far and then the last finished one (`Ui::key_of`). A string no
360/// node declared is an error naming both spellings, since nothing else
361/// would (backlog F5) — see [`key_query`] for the calls that answer instead.
362fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
363    match v {
364        mlua::Value::Integer(i) => Ok(Key(i as u64)),
365        mlua::Value::String(s) => {
366            let label = s.to_str()?;
367            ui.key_of(&label).ok_or_else(|| {
368                mlua::Error::runtime(format!(
369                    "no node is keyed {:?}: pass the label a `key` field declared in this or the last \
370                     frame, or the integer key an event carried",
371                    &*label
372                ))
373            })
374        }
375        other => Err(mlua::Error::runtime(format!(
376            "a node key is an integer or a declared label, not {}",
377            other.type_name()
378        ))),
379    }
380}
381
382/// The two spellings for a *query* — `is_hovered`, `scroll_geometry`,
383/// `edit_text` and the rest — where a name nothing declared is the answer
384/// rather than an error. Every one of them already has a "no such node"
385/// reply for a key no layout resolved (false, nil, a zero offset), and a
386/// label is the spelling a view uses *before* the node exists: the first
387/// frame of a `uniform_list` asks its own container for geometry that is
388/// not there yet. The command verbs ([`key_arg`]) keep throwing, where a
389/// typo is a bug worth naming (backlog C25). What a key may *be* is the
390/// same question for both, so anything that is not an integer or a string
391/// is refused here too.
392fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
393    match v {
394        mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
395        mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
396        other => Err(mlua::Error::runtime(format!(
397            "a node key is an integer or a declared label, not {}",
398            other.type_name()
399        ))),
400    }
401}
402
403/// A Lua sequence as a plain-data list — `lua_to_value` reads an empty
404/// table as an empty map, and a list of rows is a list even when empty.
405fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
406    let mut items = Vec::with_capacity(t.raw_len());
407    for item in t.sequence_values::<mlua::Value>() {
408        items.push(lua_to_value(&item?)?);
409    }
410    Ok(Value::List(items))
411}
412
413/// The snake spellings a script may have learned first — `select_all`,
414/// `look_up` — kept as aliases of the wire names in a row's `role`, the
415/// way `direction` is one for `repeat` (ADR 0020, decision 10). The rest
416/// of a row is the core's call (`MenuItem::from_value`).
417fn alias_menu_role(row: &mut Value) {
418    let Value::Map(fields) = row else { return };
419    for (k, v) in fields.iter_mut() {
420        if k == "role"
421            && let Value::Str(name) = v
422        {
423            match name.as_str() {
424                "select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
425                "look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
426                _ => {}
427            }
428        }
429    }
430}
431
432/// A menu's rows, from a Lua list of row tables, read by the core's one
433/// row reader.
434fn menu_items(t: &mlua::Table) -> mlua::Result<Vec<kui_core::MenuItem>> {
435    let mut rows = lua_list_to_value(t)?;
436    if let Value::List(rows) = &mut rows {
437        rows.iter_mut().for_each(alias_menu_role);
438    }
439    kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)
440}
441
442/// Host facts handed to `view(env)`, the reading `schema::ENV_FIELDS`
443/// documents and `the_env_table_is_the_documented_env_shape` pins to it key
444/// for key: `refresh_hz` (nil if unknown),
445/// `frame_budget_ms`, `focused` (the *window*'s keyboard focus, a bool),
446/// `system` (what the user set in the OS: `appearance`, `motion` and
447/// `assistive` as strings, always there because "unknown" is one of their
448/// readings, and `accent` (0xRRGGBBAA) / `locale` (a BCP-47 tag) only when
449/// the host can tell), `focus` (the focused *node*'s key), `focus_visible`,
450/// `caret_visible` (the blink phase a custom editor draws its caret on), `region`
451/// (the `focus_region` node in effect, nil for the main ring), `theme`
452/// (the palette derived from `system`: one 0xRRGGBBAA number per role in
453/// `schema::THEME_ROLES`, plus `appearance` and `disabled_opacity`),
454/// `viewport_w`/`viewport_h` (logical px), `window` chrome facts, the
455/// queries `edit_text(key)`, `is_focused(key)`, `is_hovered(key)`,
456/// `is_pressed(key)`, `scroll_offset(key)`, `scroll_geometry(key)`,
457/// `text_hit(key, x, y)` and `caret_rect(key, byte)` (each takes either
458/// spelling — the integer key an event carried or the label a `key` field
459/// declared — and answers nil/false/zero for a name no frame declared, see
460/// `key_query`; the verbs take the same two and refuse an undeclared name,
461/// see `key_arg`), the editor verb `set_edit_text(key_or_label, text)` (whose
462/// label spelling reaches an editor this view is about to declare),
463/// `measure_text(s, opts, max_w)` (see `measure_from_lua`), the
464/// focus verbs `set_focus(key)` / `blur()` / `focus_next()` / `focus_prev()` / `focus_region(key)`
465/// and the scroll calls `reveal(key)` / `scroll_offset(key)` / `set_scroll(key, x, y)` /
466/// `shift_scroll(key, drawn, target)` / `scroll_geometry(key)`, the text queries `text_hit(key, x, y)` /
467/// `caret_rect(key, byte)`, the selection calls `selection_text()` /
468/// `selection_html()` (the same words with the formatting they declared) /
469/// `selection_ends()` (the anchor and the focus as row indices and bytes,
470/// ADR 0029) / `cell_selection()` (a grid's, as absolute lines and
471/// columns) /
472/// `request_copy()` + `answer_selection_range(text)` (a copy that reaches
473/// rows a virtual list never built is asked of the app) /
474/// `set_clipboard(text, html?)` + `request_paste()` (a key sink's own
475/// Ctrl-c and Ctrl-v; the paste comes back as a `text` event with
476/// `pasted = true`, one ask at a time, and `concealed = true` /
477/// `transient = true` where the pasteboard marked it so) + `awaiting_paste()` (whether one is
478/// unanswered) + `set_clipboard_secret(text)` (a secret the host writes
479/// marked concealed and transient, backlog F84) /
480/// `select_all_in(key)` / `clear_selection()` (ADR 0017 — one selection
481/// per window, a `selectable` scope's or the focused editor's), the menu
482/// verbs `open_menu(key, x, y, items)` / `close_menu()` (whose chosen row
483/// comes back as a `menu` event on that node), the
484/// window requests `set_window_size(window,
485/// w, h)` / `focus_window(window)`, and the two calls of a script that
486/// hosts a plugin of its own: `add_extension(namespace, path)` and
487/// `extension_namespaces()`.
488fn env_table<'scope, 'env: 'scope>(
489    lua: &Lua,
490    scope: &'scope mlua::Scope<'scope, 'env>,
491    ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
492    loaded: &'env std::cell::RefCell<Vec<Loaded>>,
493) -> mlua::Result<Table> {
494    let (facts, theme, metrics) = {
495        let ui = ui.borrow();
496        (ui.env_facts(), ui.theme(), ui.metrics())
497    };
498    let t = lua.create_table()?;
499    // The tokens a script here sees this frame (ADR 0027): its own table
500    // over the host's, colours resolved for the appearance as
501    // `0xRRGGBBAA`, lengths in px — `env.tokens.colors.peach`. Roles are
502    // not listed; they are `env.theme` and `env.metrics`. Rebuilt by
503    // `env.set_tokens`, so a script reads back what it just declared.
504    t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
505    // The facts, one row of `schema::ENV_FIELDS` at a time, read by the
506    // row's own getter and filed under its snake path (`system.appearance`
507    // is `env.system.appearance`). Lua's rule for a fact the host cannot
508    // tell is `refresh_hz`'s: no key rather than a nil-shaped one, so a
509    // `Null` reading is left out. Two facts, one letter apart, are both
510    // here: `focused` is the *window*'s keyboard, `focus` the focused
511    // *node*'s key — Lua cannot converge on Node's `focused()` for the
512    // latter because the former has been `focused` since env existed.
513    //
514    // The one deliberate divergence the table names: `window.native_controls`
515    // is a rect elsewhere and two numbers here, the keep-out extent of the
516    // OS-drawn controls (macOS traffic lights) at the window origin.
517    for row in kui_core::schema::ENV_FIELDS {
518        let value = (row.get)(&facts);
519        if value == Value::Null {
520            continue;
521        }
522        if row.name == "window.native_controls" {
523            let win = t
524                .get::<Option<Table>>("window")?
525                .unwrap_or(lua.create_table()?);
526            let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
527            win.set("controls_w", f("x") + f("w"))?;
528            win.set("controls_h", f("y") + f("h"))?;
529            t.set("window", win)?;
530            continue;
531        }
532        for path in row.lua {
533            let lua_value = value_to_lua(lua, &value)?;
534            match path.split_once('.') {
535                None => t.set(*path, lua_value)?,
536                Some((head, leaf)) => {
537                    let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
538                    sub.set(leaf, lua_value)?;
539                    t.set(head, sub)?;
540                }
541            }
542        }
543    }
544    // The palette the core derived from `system`, as roles rather than
545    // values (ADR 0019). Every key is a 0xRRGGBBAA number — the same
546    // spelling a `color` prop takes — so `bg = env.theme.surface` needs no
547    // conversion; `appearance` says which base it came from and
548    // `disabled_opacity` is a multiplier, not a colour. The roles are
549    // generated from `schema::THEME_ROLES`, so a script and a Rust view
550    // read the same list under the same names. Read-only: a Lua script is
551    // a guest in someone else's frame, and the theme is the host's to set.
552    let th = lua.create_table()?;
553    for role in kui_core::schema::THEME_ROLES {
554        th.set(role.name, (role.get)(&theme).to_hex())?;
555    }
556    th.set("appearance", theme.appearance.name())?;
557    th.set("disabled_opacity", theme.disabled_opacity)?;
558    t.set("theme", th)?;
559    // The sizes the stock widgets are built from (backlog T2), generated
560    // from `schema::METRIC_ROLES` the same way: `radius = env.metrics.radius`
561    // makes a script's control agree with the host's button. Read-only for
562    // the same reason the theme is.
563    let mt = lua.create_table()?;
564    for role in kui_core::schema::METRIC_ROLES {
565        mt.set(role.name, (role.get)(&metrics))?;
566    }
567    t.set("metrics", mt)?;
568    // An editor's text, by the label its `key` field declares or by the
569    // integer key an event carried — either spelling, like every query
570    // beside it (AR26: it took the integer alone, against its own doc,
571    // and the one example kept a key from a `changed` event to work
572    // around it). A label no frame declared answers nil.
573    t.set(
574        "edit_text",
575        scope.create_function(move |_, key: mlua::Value| {
576            let mut ui = ui.borrow_mut();
577            let Some(key) = key_query(&mut ui, key)? else {
578                return Ok(None);
579            };
580            Ok(ui.edit_text(key))
581        })?,
582    )?;
583    // Replaces an editor's text, caret at the end. Named by the label its
584    // `key` field declares as well as by the integer key, and the label is
585    // the spelling an `update` that opens the field can use: the key comes
586    // from an event the editor has not fired yet (backlog F32). A label no
587    // frame has declared is held for the next frame that declares it —
588    // seeding a new editor over `initial`, replacing a retained one's draft
589    // — and dropped with an `edit-text-without-editor` warning if that
590    // frame declares nothing under it.
591    t.set(
592        "set_edit_text",
593        scope.create_function(move |_, (key, text): (mlua::Value, String)| {
594            let mut ui = ui.borrow_mut();
595            match key {
596                mlua::Value::String(label) => {
597                    ui.set_edit_text_by_label(&label.to_str()?, &text);
598                }
599                other => {
600                    let key = key_arg(&mut ui, other)?;
601                    ui.set_edit_text(key, &text);
602                }
603            }
604            Ok(())
605        })?,
606    )?;
607    // Takes a label too (`key_arg`): a view styles the row it declares by
608    // the name it gives it, without an event having told it the key.
609    t.set(
610        "is_focused",
611        scope.create_function(move |_, key: mlua::Value| {
612            let mut ui = ui.borrow_mut();
613            let Some(key) = key_query(&mut ui, key)? else {
614                return Ok(false);
615            };
616            Ok(ui.is_focused(key))
617        })?,
618    )?;
619    t.set(
620        "is_hovered",
621        scope.create_function(move |_, key: mlua::Value| {
622            let mut ui = ui.borrow_mut();
623            let Some(key) = key_query(&mut ui, key)? else {
624                return Ok(false);
625            };
626            Ok(ui.is_hovered(key))
627        })?,
628    )?;
629    // Held down: the press started on this node and the pointer is still
630    // over it (or it captured a drag). Goes with `is_hovered` — a script
631    // that draws its own button styles the pressed state from this.
632    t.set(
633        "is_pressed",
634        scope.create_function(move |_, key: mlua::Value| {
635            let mut ui = ui.borrow_mut();
636            let Some(key) = key_query(&mut ui, key)? else {
637                return Ok(false);
638            };
639            Ok(ui.is_pressed(key))
640        })?,
641    )?;
642    // Files dragged in from the OS are over this node (ADR 0031): for
643    // drop-dependent *layout*; the colour swap is the `drop_bg` prop.
644    t.set(
645        "is_drop_target",
646        scope.create_function(move |_, key: mlua::Value| {
647            let mut ui = ui.borrow_mut();
648            let Some(key) = key_query(&mut ui, key)? else {
649                return Ok(false);
650            };
651            Ok(ui.is_drop_target(key))
652        })?,
653    )?;
654    // The `on_drop` zone the dragged files are over — its key, nil for
655    // none.
656    t.set(
657        "drop_target",
658        scope.create_function(move |_, ()| {
659            let ui = ui.borrow();
660            Ok(ui.drop_target().map(|k| k.0 as i64))
661        })?,
662    )?;
663    // Moving focus from the script, the imperative half of `key_focus`.
664    // `set_focus`, not `focus`: `env.focus` is already the reading above
665    // and alpha.5 shipped it, so the verb takes the longer name rather
666    // than change what a name means under a script that already runs.
667    // `blur` / `focus_next` / `focus_prev` match Node and C exactly.
668    // The key is an integer or a declared label (`key_arg`): "focus the
669    // editor I just created" is `env.set_focus("editor")`, with no event
670    // from it needed first.
671    t.set(
672        "set_focus",
673        scope.create_function(move |_, key: mlua::Value| {
674            let mut ui = ui.borrow_mut();
675            let key = key_arg(&mut ui, key)?;
676            ui.focus(key);
677            Ok(())
678        })?,
679    )?;
680    t.set(
681        "blur",
682        scope.create_function(move |_, ()| {
683            ui.borrow_mut().blur();
684            Ok(())
685        })?,
686    )?;
687    // What Tab and Shift-Tab do: the next / previous focusable node in
688    // tree order, wrapping. A script that binds Tab in an `on_key` sink
689    // calls these to hand the keyboard on. Like `reveal`, the step
690    // resolves when *this* frame finishes — the ring is made of a
691    // finished tree, and the script is still declaring one — so a view
692    // can step onto a row it is declaring right now.
693    t.set(
694        "focus_next",
695        scope.create_function(move |_, ()| {
696            ui.borrow_mut().focus_next();
697            Ok(())
698        })?,
699    )?;
700    t.set(
701        "focus_prev",
702        scope.create_function(move |_, ()| {
703            ui.borrow_mut().focus_prev();
704            Ok(())
705        })?,
706    )?;
707    // Enters a focus region — `env.focus_region("dock")`, the label or the
708    // integer key of a node declared `focus_region = true` — or the main
709    // ring for nil (`docs/adr/0022-focus-regions.md`). Resolves when this
710    // frame finishes, like `focus_next`, so a script may name the region
711    // it is declaring right now — call it after the region's node.
712    t.set(
713        "focus_region",
714        scope.create_function(move |_, key: mlua::Value| {
715            let mut ui = ui.borrow_mut();
716            let key = match key {
717                mlua::Value::Nil => None,
718                v => Some(key_arg(&mut ui, v)?),
719            };
720            ui.focus_region(key);
721            Ok(())
722        })?,
723    )?;
724    // `env.announce(text, politeness)` says something once, with no node
725    // behind it (`docs/adr/0008-live-regions-and-announcements.md`).
726    // `politeness` is "polite" (the default) or "assertive"; "off" and an
727    // empty text are no-ops. A region whose message is on screen is the
728    // `live` prop instead.
729    //
730    // `env` exists only inside `view`, and a view runs every frame, so a
731    // call here needs a guard the script clears — `on_event` sets a field,
732    // `view` announces it and clears it. The core reports the unguarded
733    // case as `announcement-repeated`.
734    t.set(
735        "announce",
736        scope.create_function(move |_, (text, live): (String, Option<String>)| {
737            let live = live.unwrap_or_else(|| "polite".to_string());
738            let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
739                return Err(mlua::Error::runtime(format!(
740                    "bad politeness {live:?} (one of {})",
741                    schema::LIVE.join(" | ")
742                )));
743            };
744            ui.borrow_mut()
745                .announce(&text, kui_core::Live::from_index(i));
746            Ok(())
747        })?,
748    )?;
749    // Scrolling from the script: `env.reveal(key)` scrolls whatever
750    // contains a node so it shows, and `env.scroll_offset` /
751    // `env.set_scroll` read and write a container's retained offset.
752    // `view` runs while the frame is being built, so a reveal resolves
753    // against *this* frame's layout when it finishes — which is what lets
754    // a script reveal a row it is declaring right now. A key the frame
755    // does not declare, or one with nothing scrollable above it, is a
756    // no-op; the request is not kept for a later frame. A label no node
757    // has declared yet — the row this `view` declares further down, or a
758    // pane's first frame — is resolved when the frame finishes, and one
759    // that frame does not declare either is a `label-without-node`
760    // warning (backlog DX15), where it was an error scripts wrapped in
761    // `pcall` and retried.
762    t.set(
763        "reveal",
764        scope.create_function(move |_, key: mlua::Value| {
765            let mut ui = ui.borrow_mut();
766            match key {
767                mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
768                    ui.reveal_label(&s.to_str()?);
769                }
770                key => {
771                    let key = key_arg(&mut ui, key)?;
772                    ui.reveal(key);
773                }
774            }
775            Ok(())
776        })?,
777    )?;
778    // `{x, y}` as the last layout clamped it (positive = content moved up
779    // / left); zeroes for a node that never scrolled. Stash it and hand it
780    // back to `set_scroll` to restore a position.
781    t.set(
782        "scroll_offset",
783        scope.create_function(move |lua, key: mlua::Value| {
784            let mut ui = ui.borrow_mut();
785            let off = match key_query(&mut ui, key)? {
786                Some(key) => ui.scroll_offset(key),
787                None => kui_core::Vec2::ZERO,
788            };
789            let r = lua.create_table()?;
790            r.set("x", off.x)?;
791            r.set("y", off.y)?;
792            Ok(r)
793        })?,
794    )?;
795    // Everything the last layout resolved for a container: its box
796    // `{x, y, w, h}`, its content `{content_w, content_h}` and the clamped
797    // `{offset = {x, y}}`; nil for a key no layout has resolved as one.
798    // This is what lets a script draw a long list affordably — the core
799    // builds every child the view declares, so a script that knows `h` and
800    // `offset.y` declares the rows that fit plus two spacers holding the
801    // space of the rest. It describes the frame before this one, so a
802    // resize slices one frame late; declare a row or two extra at each end.
803    // Where a point (the `x`, `y` a click or drag event carried) lands in
804    // the text a keyed node drew: `{ byte, line }` — the byte offset into
805    // that text, across the node's runs in order (a `role="none"` subtree
806    // skipped), and the visual row within the node, counted across its
807    // runs (AR30) — or nil for a key that drew no text. Answered from the frame that
808    // finished, which is the layout the pointer was over.
809    t.set(
810        "text_hit",
811        scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
812            let mut ui = ui.borrow_mut();
813            let Some(key) = key_query(&mut ui, key)? else {
814                return Ok(mlua::Value::Nil);
815            };
816            let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
817                return Ok(mlua::Value::Nil);
818            };
819            value_to_lua(lua, &h.to_value())
820        })?,
821    )?;
822    // Opens a context menu at (x, y) over a keyed node, its items a list
823    // of tables: `{ label=, role=, enabled=, checked=, id=, accel= }`, all
824    // but `label` optional, `role` one of "custom" (the default),
825    // "separator", "cut", "copy", "paste", "selectAll", "lookUp" — the
826    // spelling the `menu` event reports back ("select_all" / "look_up"
827    // are taken too). Choosing a row posts `{kind="menu", role, item}` on
828    // the node and closes the menu (docs/adr/0017-selection-as-a-scope.md).
829    t.set(
830        "open_menu",
831        scope.create_function(
832            move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
833                let mut ui = ui.borrow_mut();
834                let Some(target) = key_query(&mut ui, key)? else {
835                    return Ok(false);
836                };
837                let items = menu_items(&items)?;
838                ui.open_menu(kui_core::Menu::new(
839                    target,
840                    kui_core::Vec2::new(x, y),
841                    items,
842                ));
843                Ok(true)
844            },
845        )?,
846    )?;
847    // Closes whatever menu is open; true when there was one.
848    t.set(
849        "close_menu",
850        scope.create_function(move |_, ()| {
851            let mut ui = ui.borrow_mut();
852            Ok(ui.close_menu())
853        })?,
854    )?;
855    // The window's selected text: what a `selectable` scope holds, or the
856    // focused `edit`'s selection — one per window, so there is no choice
857    // to make. Nil with no selection, `""` when one exists and covers
858    // nothing (docs/adr/0017-selection-as-a-scope.md).
859    t.set(
860        "selection_text",
861        scope.create_function(move |_, ()| {
862            let ui = ui.borrow();
863            Ok(ui.selection_text())
864        })?,
865    )?;
866    // Asks for the selection as text: returns the text, or nil and true
867    // when the app was asked instead — a selection that reaches rows a
868    // virtual list never built posts `{kind="selectionrange", from={index,
869    // byte}, to={index, byte}}` on the scope, and the app answers with
870    // `answer_selection_range` (docs/adr/0017-selection-as-a-scope.md).
871    t.set(
872        "request_copy",
873        scope.create_function(move |_, ()| {
874            let mut ui = ui.borrow_mut();
875            Ok(match ui.request_copy() {
876                kui_core::CopyRequest::Ready(text) => (Some(text), false),
877                kui_core::CopyRequest::Asked => (None, true),
878                kui_core::CopyRequest::Nothing => (None, false),
879            })
880        })?,
881    )?;
882    // Answers a `selectionrange` ask with the text for the range it named,
883    // whole. False when nothing asked.
884    t.set(
885        "answer_selection_range",
886        scope.create_function(move |_, text: String| {
887            let mut ui = ui.borrow_mut();
888            Ok(ui.answer_selection_range(&text))
889        })?,
890    )?;
891    // The clipboard for a script that owns its text (backlog C33): a key
892    // sink hears the raw Ctrl-c / Ctrl-v and binds them here. Both queue
893    // the action a menu's Copy or Paste would, for the host to apply at
894    // its next drain; a paste comes back as a `text` event on the focused
895    // sink (or as typing into a focused editor).
896    t.set(
897        "set_clipboard",
898        scope.create_function(move |_, (text, html): (String, Option<String>)| {
899            ui.borrow_mut().set_clipboard(text, html);
900            Ok(())
901        })?,
902    )?;
903    // A secret, which the host writes marked concealed and transient the
904    // way a password manager does, so no clipboard manager shows or keeps
905    // it (backlog F84).
906    t.set(
907        "set_clipboard_secret",
908        scope.create_function(move |_, text: String| {
909            ui.borrow_mut().set_clipboard_secret(text);
910            Ok(())
911        })?,
912    )?;
913    t.set(
914        "request_paste",
915        scope.create_function(move |_, ()| {
916            ui.borrow_mut().request_paste();
917            Ok(())
918        })?,
919    )?;
920    t.set(
921        "awaiting_paste",
922        scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
923    )?;
924    // The platform's Open, Save or folder dialog (backlog C51): `{ mode =
925    // "open"|"save"|"folder", multiple, title, filters = {{ name, extensions
926    // = {...} }}, directory, file_name, tag }`, every field optional. The
927    // answer is a `files` event to this script — `paths` empty when the
928    // user cancelled. False when one is already out.
929    t.set(
930        "request_files",
931        scope.create_function(move |_, opts: Option<mlua::Value>| {
932            let v = match &opts {
933                Some(o) => lua_to_value(o)?,
934                None => Value::Null,
935            };
936            let dialog = kui_core::FileDialog::from_value(&v)
937                .map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
938            Ok(ui.borrow_mut().request_files(dialog))
939        })?,
940    )?;
941    t.set(
942        "awaiting_files",
943        scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
944    )?;
945    // The selection as HTML: the formatting the text declared (bold,
946    // The text selection's two ends as the drag made them: `{anchor =
947    // {index, byte}, focus = {index, byte}}`, `index` the data index of
948    // the virtualised row the end is in (nil outside one) and `byte` the
949    // offset in that row's own text. Directed, so a Shift-click that kept
950    // the anchor reads as one (ADR 0029). Nil with no text selection.
951    t.set(
952        "selection_ends",
953        scope.create_function(move |lua, ()| {
954            let ui = ui.borrow();
955            let Some((a, f)) = ui.selection_ends() else {
956                return Ok(mlua::Value::Nil);
957            };
958            let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
959                let t = lua.create_table()?;
960                if let Some(r) = e.row {
961                    t.set("index", r)?;
962                }
963                t.set("byte", e.byte)?;
964                Ok(t)
965            };
966            let out = lua.create_table()?;
967            out.set("anchor", end(a)?)?;
968            out.set("focus", end(f)?)?;
969            Ok(mlua::Value::Table(out))
970        })?,
971    )?;
972    // A `cells` grid's selection, the window's when it lives in one:
973    // `{node, anchor = {line, col}, focus = {line, col}, block}`, the
974    // lines absolute (`origin_line` plus the row, so a scroll does not
975    // move them) and the ends as the drag made them (ADR 0017, decision
976    // 4). Nil when the window's selection is not a grid's; a text
977    // selection's ends are `selection_ends()`. The row ADR 0017 §4
978    // offered and only Rust had (backlog B1a).
979    t.set(
980        "cell_selection",
981        scope.create_function(move |lua, ()| {
982            let ui = ui.borrow();
983            match ui.cell_selection() {
984                Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
985                None => Ok(mlua::Value::Nil),
986            }
987        })?,
988    )?;
989    // italic, a span's own colour) and not the node's colour, which is
990    // the theme's. Nil with no text selection. A second clipboard flavour
991    // beside the plain text, never instead of it.
992    t.set(
993        "selection_html",
994        scope.create_function(move |_, ()| {
995            let ui = ui.borrow();
996            Ok(ui.selection_html())
997        })?,
998    )?;
999    // Selects every run inside the scope a keyed node declared, first
1000    // byte to last — Select All, scoped. False for a node that drew no
1001    // text or is not a scope. Runs the frame built but never drew are
1002    // part of it.
1003    t.set(
1004        "select_all_in",
1005        scope.create_function(move |_, key: mlua::Value| {
1006            let mut ui = ui.borrow_mut();
1007            let Some(key) = key_query(&mut ui, key)? else {
1008                return Ok(false);
1009            };
1010            Ok(ui.select_all_in(key))
1011        })?,
1012    )?;
1013    // Drops the window's selection, whichever it is; true when there was
1014    // one to drop.
1015    t.set(
1016        "clear_selection",
1017        scope.create_function(move |_, ()| {
1018            let mut ui = ui.borrow_mut();
1019            Ok(ui.clear_selection())
1020        })?,
1021    )?;
1022    // The caret rect for a byte offset in that text: `{ x, y, w, h }`,
1023    // logical viewport px, zero wide, one line tall; nil for a key that
1024    // drew no text. A byte past the text is the end.
1025    t.set(
1026        "caret_rect",
1027        scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
1028            let mut ui = ui.borrow_mut();
1029            let Some(key) = key_query(&mut ui, key)? else {
1030                return Ok(mlua::Value::Nil);
1031            };
1032            let Some(r) = ui.caret_rect(key, byte) else {
1033                return Ok(mlua::Value::Nil);
1034            };
1035            value_to_lua(lua, &r.to_value())
1036        })?,
1037    )?;
1038    t.set(
1039        "scroll_geometry",
1040        scope.create_function(move |lua, key: mlua::Value| {
1041            let mut ui = ui.borrow_mut();
1042            let Some(key) = key_query(&mut ui, key)? else {
1043                return Ok(mlua::Value::Nil);
1044            };
1045            let Some(g) = ui.scroll_geometry(key) else {
1046                return Ok(mlua::Value::Nil);
1047            };
1048            // The core's shape, whose keys are already Lua's spelling.
1049            value_to_lua(lua, &g.to_value())
1050        })?,
1051    )?;
1052    // The rect the last frame laid an `on_layout` node out at — the
1053    // `layout` event's numbers without the event (backlog C26 step 2).
1054    t.set(
1055        "layout_of",
1056        scope.create_function(move |lua, key: mlua::Value| {
1057            let mut ui = ui.borrow_mut();
1058            let Some(key) = key_query(&mut ui, key)? else {
1059                return Ok(mlua::Value::Nil);
1060            };
1061            let Some(r) = ui.layout_of(key) else {
1062                return Ok(mlua::Value::Nil);
1063            };
1064            value_to_lua(lua, &r.to_value())
1065        })?,
1066    )?;
1067    // The wheel's move by hand; the next layout clamps it, so 0,0 is "jump
1068    // to the top" and a huge y is "jump to the end".
1069    t.set(
1070        "set_scroll",
1071        scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
1072            let mut ui = ui.borrow_mut();
1073            let at = kui_core::Vec2::new(x, y);
1074            // A label not declared yet waits for the frame's end, as
1075            // `reveal`'s does (backlog DX15).
1076            match key {
1077                mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1078                    ui.set_scroll_label(&s.to_str()?, at);
1079                }
1080                key => {
1081                    let key = key_arg(&mut ui, key)?;
1082                    ui.set_scroll(key, at);
1083                }
1084            }
1085            Ok(())
1086        })?,
1087    )?;
1088    // A correction by content that moved under the list, on y, with no
1089    // ease: what the prelude's `list` asks for when the rows it measured
1090    // came out another height than their estimate (RG18, backlog C46). A
1091    // key nothing declared yet is the first frame, with nothing to correct.
1092    t.set(
1093        "shift_scroll",
1094        scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
1095            let mut ui = ui.borrow_mut();
1096            if let Some(key) = key_query(&mut ui, key)? {
1097                ui.shift_scroll(
1098                    key,
1099                    kui_core::Vec2::new(0.0, drawn),
1100                    kui_core::Vec2::new(0.0, target),
1101                );
1102            }
1103            Ok(())
1104        })?,
1105    )?;
1106    // Window requests from the script: `env.set_window_size(window, w, h)`
1107    // and `env.focus_window(window)` queue commands the driver applies on
1108    // its next pump — after this frame, since `view` runs inside one — and
1109    // a headless driver never drains. Requests, not declarations: the user
1110    // owns a window's size once it exists (ADR 0004 decision 5).
1111    // `env.window.id` is the window the script is drawing, and the only
1112    // one there is until step 3.
1113    t.set(
1114        "set_window_size",
1115        scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
1116            ui.borrow_mut()
1117                .set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
1118            Ok(())
1119        })?,
1120    )?;
1121    // Declare this script's tokens from inside a view (ADR 0027): the
1122    // same shape as the `tokens` global, replacing this origin's table
1123    // whole, in effect for the nodes the same view opens after the call.
1124    // A script whose lengths follow a tier declares on each change.
1125    let env = t.clone();
1126    t.set(
1127        "set_tokens",
1128        scope.create_function(move |lua, decl: Table| {
1129            let tokens = parse_tokens(&decl)?;
1130            ui.borrow_mut().set_tokens(tokens);
1131            env.set("tokens", tokens_table(lua, &ui.borrow())?)
1132        })?,
1133    )?;
1134    t.set(
1135        "focus_window",
1136        scope.create_function(move |_, window: i64| {
1137            ui.borrow_mut()
1138                .focus_window(kui_core::WindowId(window as u32));
1139            Ok(())
1140        })?,
1141    )?;
1142    t.set(
1143        "measure_text",
1144        scope.create_function(
1145            move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
1146                let mut guard = ui.borrow_mut();
1147                let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
1148                value_to_lua(lua, &m.to_value())
1149            },
1150        )?,
1151    )?;
1152    // `env.add_extension(namespace, path)` → true, or nil and a message:
1153    // the script hosting an extension of its own (ADR 0014, and
1154    // `kui_core::slot`). The mechanism is C shared libraries and only
1155    // that — a `.so` / `.dylib` / `.dll` exporting the seven `kui_ext_*`
1156    // entry points `crates/kui-ffi/include/kui.h` describes — which is
1157    // the same plugin a Rust, C or Node host loads, and is why a script
1158    // can place one at all: the contract between a host and an extension
1159    // is C, so the script is just another host.
1160    //
1161    // Called from `view`, because that is where a script knows what it
1162    // wants, and idempotent by (namespace, path) so the honest spelling
1163    // is to call it every frame. The namespace joins the frame's *one*
1164    // map, so it can collide with the host's; a taken one is the error.
1165    // Nothing here is a new capability: `Lua::new` has `package`, so a
1166    // script could already `package.loadlib` anything on the disk. What
1167    // this adds is a plugin that draws in the script's own tree.
1168    t.set(
1169        "add_extension",
1170        scope.create_function(move |_, (namespace, path): (String, String)| {
1171            let path = std::path::PathBuf::from(path);
1172            if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
1173                return Ok(if prev.path == path {
1174                    // The every-frame call, already answered.
1175                    (Some(true), None)
1176                } else {
1177                    (
1178                        None,
1179                        Some(format!(
1180                            "`{namespace}` is already {} in this script; a second plugin needs a \
1181                             second namespace",
1182                            prev.path.display()
1183                        )),
1184                    )
1185                });
1186            }
1187            // SAFETY: no more so than the host loading it would be. The
1188            // library's code runs in this process on this frame; a script
1189            // naming one is trusting it as the host trusts the script.
1190            let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
1191                Ok(ext) => ext,
1192                Err(e) => return Ok((None, Some(e))),
1193            };
1194            let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
1195                Ok(origin) => origin,
1196                Err(e) => return Ok((None, Some(e))),
1197            };
1198            loaded.borrow_mut().push(Loaded {
1199                namespace,
1200                path,
1201                origin,
1202            });
1203            Ok((Some(true), None))
1204        })?,
1205    )?;
1206    // The namespaces this script loaded, in the order it asked for them —
1207    // what it can name in a `fill`, and what a reply's `from` will say.
1208    t.set(
1209        "extension_namespaces",
1210        scope.create_function(move |lua, ()| {
1211            lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
1212        })?,
1213    )?;
1214    Ok(t)
1215}
1216
1217/// `env.measure_text(s, opts, max_w)` → `{ width, height, lines }` (logical
1218/// px): what layout would give a text node with that content and style,
1219/// wrapped to `max_w` when given. `s` is a string, a span list (the same
1220/// shape `text({...})` takes) or a whole `text(...)` node table, whose own
1221/// props are then the style; `opts` is a style table (`size`, `font`,
1222/// `wrap`, `max_lines`, `ellipsis`, ...). The metrics do not scale
1223/// linearly: `measured × zoom` is not `measure(size × zoom)`, because
1224/// shaping rounds per size, so anything that zooms measures at the size it
1225/// draws.
1226/// `env.tokens`: the frame's resolved tokens, own over host, as two
1227/// tables of name → value.
1228fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
1229    let look = ui.tokens();
1230    let tokens = lua.create_table()?;
1231    let colors = lua.create_table()?;
1232    for (name, c) in look.colors() {
1233        colors.set(name, c.to_hex())?;
1234    }
1235    let lengths = lua.create_table()?;
1236    for (name, v) in look.lengths() {
1237        lengths.set(name, v)?;
1238    }
1239    tokens.set("colors", colors)?;
1240    tokens.set("lengths", lengths)?;
1241    Ok(tokens)
1242}
1243
1244fn measure_from_lua(
1245    ui: &mut Ui<'_>,
1246    s: &mlua::Value,
1247    opts: Option<&Table>,
1248    max_w: Option<f32>,
1249) -> mlua::Result<kui_core::TextMetrics> {
1250    let style = match opts {
1251        Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
1252        None => kui_core::TextStyle::default(),
1253    };
1254    let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
1255        let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
1256        let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1257        Ok(ui.measure_rich_text(&spans, style, max_w))
1258    };
1259    match s {
1260        mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
1261        mlua::Value::Table(t) => {
1262            if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
1263                let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1264                match t.get::<Option<Table>>("spans")? {
1265                    Some(spans) => measure_spans(ui, &spans, &style),
1266                    None => {
1267                        let value: String = t.get("value")?;
1268                        Ok(ui.measure_text(&value, &style, max_w))
1269                    }
1270                }
1271            } else {
1272                measure_spans(ui, t, &style)
1273            }
1274        }
1275        other => Err(bad(format!(
1276            "measure_text: expected a string, a span list or a text node, got {}",
1277            other.type_name()
1278        ))),
1279    }
1280}
1281
1282// ---------------------------------------------------------------------------
1283// Table tree -> IR
1284
1285fn bad(msg: impl std::fmt::Display) -> mlua::Error {
1286    mlua::Error::runtime(msg.to_string())
1287}
1288
1289fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1290    for child in t.sequence_values::<Table>() {
1291        build_node(ui, &child?)?;
1292    }
1293    Ok(())
1294}
1295
1296/// Builds `t`'s children inside a widget's content closure, carrying the
1297/// first error out (widget closures can't return one).
1298fn with_children(
1299    ui: &mut Ui<'_>,
1300    t: &Table,
1301    widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
1302) -> mlua::Result<()> {
1303    let mut result = Ok(());
1304    widget(ui, &mut |ui| result = build_children(ui, t));
1305    result
1306}
1307
1308/// The `ELEMENTS` row a Lua node type is: the two container constructors are
1309/// one element, `input` is the chrome around an `edit`, and the widget
1310/// functions spell their names with underscores.
1311fn element_of(ty: &str) -> &str {
1312    match ty {
1313        "row" | "column" => "box",
1314        "grid" => "table",
1315        "input" => "edit",
1316        "dropdown" => "select",
1317        "radio_group" => "radioGroup",
1318        "window_buttons" => "windowButtons",
1319        "menu_bar" => "menuBar",
1320        "latency_graph" | "latency_hud" => "latencyGraph",
1321        other => other,
1322    }
1323}
1324
1325fn menu_bar_of(t: &Table) -> mlua::Result<kui_core::MenuBar> {
1326    let Some(list) = t.get::<Option<Table>>("menu")? else {
1327        return Ok(kui_core::MenuBar::default());
1328    };
1329    let mut menus = lua_list_to_value(&list)?;
1330    if let Value::List(menus) = &mut menus {
1331        for menu in menus.iter_mut() {
1332            let Value::Map(fields) = menu else { continue };
1333            for (k, items) in fields.iter_mut() {
1334                if k != "items" {
1335                    continue;
1336                }
1337                // An empty Lua table read as a map is an empty row list.
1338                if matches!(items, Value::Map(m) if m.is_empty()) {
1339                    *items = Value::List(Vec::new());
1340                }
1341                if let Value::List(rows) = items {
1342                    rows.iter_mut().for_each(alias_menu_role);
1343                }
1344            }
1345        }
1346    }
1347    kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)
1348}
1349
1350fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
1351    let Some(list) = root.get::<Option<Table>>("windows")? else {
1352        return Ok(());
1353    };
1354    // Plain data with a fixed shape, read by the core (`WindowConfig::
1355    // from_value`): a name, or `{name, kind?, width?, height?, activates?,
1356    // anchor?}`.
1357    for entry in list.sequence_values::<mlua::Value>() {
1358        let v = lua_to_value(&entry?)?;
1359        let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
1360        ui.window(&name, cfg);
1361    }
1362    Ok(())
1363}
1364
1365/// Warns about every key in the node table that no table claims — the
1366/// binding is about to drop it (see `diag::UNKNOWN_PROP`). Only string keys:
1367/// children sit at the integer ones.
1368fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
1369    if !ui.core().diagnostics() {
1370        return Ok(());
1371    }
1372    for pair in t.pairs::<mlua::Value, mlua::Value>() {
1373        let (k, _) = pair?;
1374        let mlua::Value::String(k) = k else { continue };
1375        let name = k.to_str()?;
1376        // `type` is the prelude's element tag, not a prop.
1377        if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
1378            continue;
1379        }
1380        let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
1381        ui.core().warn(w);
1382    }
1383    Ok(())
1384}
1385
1386/// `fill { name = "todos/panel", params = {...} }`: a position an
1387/// extension fills, in place (ADR 0014). Not a node and so not a schema
1388/// element — it draws nothing itself and takes none of the props a box
1389/// takes, which is why it is checked here rather than by `check_props`.
1390/// `name` is the full `namespace/slot`: the namespace this script loaded
1391/// the plugin under (`env.add_extension`) and the slot in the plugin's own
1392/// vocabulary. `params` is whatever the plugin should read this frame —
1393/// plain data, declared every frame, retained by nobody, exactly like an
1394/// `on_click` payload.
1395fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1396    let name: String = t
1397        .get::<Option<String>>("name")?
1398        .filter(|n| !n.is_empty())
1399        .ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
1400    if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
1401        return Err(bad(format!(
1402            "bad slot name {name:?} (a full \"namespace/slot\")"
1403        )));
1404    }
1405    for pair in t.pairs::<mlua::Value, mlua::Value>() {
1406        let (k, _) = pair?;
1407        let mlua::Value::String(k) = k else { continue };
1408        let k = k.to_str()?;
1409        if !matches!(k.as_ref(), "type" | "name" | "params") {
1410            return Err(bad(format!(
1411                "fill takes name and params, not {:?} — it is a position, not a box",
1412                k.as_ref()
1413            )));
1414        }
1415    }
1416    let params = match t.get::<mlua::Value>("params")? {
1417        mlua::Value::Nil => Value::Null,
1418        v => lua_to_value(&v)?,
1419    };
1420    ui.slot_with(&name, &params);
1421    Ok(())
1422}
1423
1424/// `devtools_tab { name = , label = , slot = }` or `devtools_tab { name = ,
1425/// label = , view = function() … end }` (ADR 0032): a tab in the core's
1426/// devtools panel, an extension's through the slot it names, or the
1427/// script's own through `view`, which is called only while the tab is on
1428/// show — the same rule the Rust closure and the C open answer — and
1429/// whose returned tree is the tab's content. Not a node and not a schema
1430/// element, like `fill`. A declaration the converter cannot read as
1431/// either form warns `bad-devtools-tab` and declares nothing.
1432fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1433    let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
1434    let refuse = |ui: &mut Ui<'_>, why: &str| {
1435        ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
1436        Ok(())
1437    };
1438    if name.is_empty() {
1439        return refuse(ui, "it needs a name (its identity)");
1440    }
1441    let label: String = t
1442        .get::<Option<String>>("label")?
1443        .unwrap_or_else(|| name.clone());
1444    for pair in t.pairs::<mlua::Value, mlua::Value>() {
1445        let (k, _) = pair?;
1446        let mlua::Value::String(k) = k else { continue };
1447        let k = k.to_str()?;
1448        if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
1449            return refuse(
1450                ui,
1451                &format!(
1452                    "it takes name, label and slot or view, not {:?}",
1453                    k.as_ref()
1454                ),
1455            );
1456        }
1457    }
1458    let slot = t.get::<Option<String>>("slot")?;
1459    let view = t.get::<mlua::Value>("view")?;
1460    match (slot, view) {
1461        (Some(slot), mlua::Value::Nil) => {
1462            if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
1463                return refuse(
1464                    ui,
1465                    &format!("bad slot {slot:?} (a full \"namespace/slot\")"),
1466                );
1467            }
1468            ui.devtools_tab(&name, &label, &slot);
1469            Ok(())
1470        }
1471        (None, mlua::Value::Function(view)) => {
1472            let mut result = Ok(());
1473            ui.devtools_tab_with(&name, &label, |ui| {
1474                result = view
1475                    .call::<Table>(())
1476                    .and_then(|tree| build_node(ui, &tree));
1477            });
1478            result
1479        }
1480        (Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
1481        (None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
1482        (_, other) => refuse(
1483            ui,
1484            &format!("view is a {}, not a function", other.type_name()),
1485        ),
1486    }
1487}
1488
1489/// How deep a Lua view tree may nest: twice `deep_nesting_64_levels`,
1490/// and inside a debug build's 2 MB test thread whatever the nodes on the
1491/// way (a chain of tooltips, radio groups and fragments overflowed it
1492/// between 128 and 256). Past it, or on a node that is its own ancestor
1493/// (`t[1] = t`), [`build_node`] refuses the view rather than recurse off
1494/// the stack (backlog RG95).
1495pub const MAX_VIEW_DEPTH: usize = 128;
1496
1497thread_local! {
1498    /// The view tables [`build_node`] is inside, outermost first. The
1499    /// widget closures between a node and its children cannot carry the
1500    /// path as an argument.
1501    static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
1502        const { std::cell::RefCell::new(Vec::new()) };
1503}
1504
1505fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1506    let at = t.to_pointer();
1507    VIEW_PATH.with_borrow_mut(|path| {
1508        if path.contains(&at) {
1509            return Err(bad("a view node that holds itself"));
1510        }
1511        if path.len() >= MAX_VIEW_DEPTH {
1512            return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
1513        }
1514        path.push(at);
1515        Ok(())
1516    })?;
1517    let built = build_one(ui, t);
1518    VIEW_PATH.with_borrow_mut(|path| path.pop());
1519    built
1520}
1521
1522/// One view node. The containers are the path a deep view recurses
1523/// down, so their frame stays small: the props are parsed in
1524/// [`open_box`] and every other node type in [`build_widget`], each
1525/// frame gone before the children are built. One function holding the
1526/// whole match was 64 KB of stack a level in a debug build, and 32
1527/// nested columns overflowed a test thread (backlog RG95).
1528fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1529    let ty: String = t.get("type")?;
1530    match ty.as_str() {
1531        "fill" => build_fill(ui, t),
1532        "devtools_tab" => build_devtools_tab(ui, t),
1533        "row" | "column" | "grid" => {
1534            open_box(ui, t, &ty)?;
1535            build_children(ui, t)?;
1536            ui.close();
1537            Ok(())
1538        }
1539        "fragment" => {
1540            open_fragment(ui, t)?;
1541            build_children(ui, t)?;
1542            ui.close();
1543            Ok(())
1544        }
1545        "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, &ty),
1546        _ => build_widget(ui, t, &ty),
1547    }
1548}
1549
1550#[inline(never)]
1551fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1552    check_props(ui, t, element_of(ty))?;
1553    let mut p = with_refs(ui, |refs| parse_props(t, ty == "row", refs))?;
1554    // A grid is a column whose rows' cells line up (ADR 0033).
1555    p.spec.layout.table = ty == "grid";
1556    ui.core().open_from(p, Content::Box);
1557    Ok(())
1558}
1559
1560#[inline(never)]
1561fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1562    check_props(ui, t, element_of("fragment"))?;
1563    // Handle from the host (kui_fragment_add / Core::add_fragment),
1564    // passed to scripts as a plain integer, like an image's — and
1565    // `image`, the image handle the function samples (backlog V1),
1566    // absent or 0 for none.
1567    let id: i64 = t.get("id")?;
1568    let image: Option<i64> = t.get("image")?;
1569    let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
1570        Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
1571        None => Vec::new(),
1572    };
1573    let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1574    let frag = kui_core::FragmentRef {
1575        id: kui_core::FragmentId::from_ffi(id as u64),
1576        image: image
1577            .filter(|i| *i != 0)
1578            .map(|i| kui_core::ImageId::from_ffi(i as u64)),
1579    };
1580    ui.core().open_from(p, Content::Fragment(frag, &params));
1581    Ok(())
1582}
1583
1584/// The widgets that hold children besides the boxes, apart from the
1585/// rest for the same reason as [`open_box`].
1586#[inline(never)]
1587fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1588    check_props(ui, t, element_of(ty))?;
1589    match ty {
1590        "titlebar" => {
1591            if t.raw_len() > 0 {
1592                with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1593            } else {
1594                let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1595                widgets::titlebar(ui, &title);
1596                Ok(())
1597            }
1598        }
1599        "tooltip" => {
1600            if t.raw_len() > 0 {
1601                with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1602            } else {
1603                let value: String = t.get("value")?;
1604                widgets::tooltip(ui, &value);
1605                Ok(())
1606            }
1607        }
1608        "radio_group" => {
1609            // Every box row; the role, the name and, with no `gap`, the
1610            // stock spacing are the group's (`widgets::radio_group_with`).
1611            let label: String = t.get("label")?;
1612            let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1613            let mut result = Ok(());
1614            widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1615            result
1616        }
1617        other => unreachable!("{other} holds no children"),
1618    }
1619}
1620
1621#[inline(never)]
1622fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1623    check_props(ui, t, element_of(ty))?;
1624    match ty {
1625        "text" => {
1626            let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1627            if let Some(spans) = t.get::<Option<Table>>("spans")? {
1628                let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1629                let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1630                ui.rich_text(&spans, style);
1631            } else {
1632                let value: String = t.get("value")?;
1633                ui.text(&value, style);
1634            }
1635            Ok(())
1636        }
1637        "image" => {
1638            // Handle from the host (kui_image_add / Resources::add_image),
1639            // passed to scripts as a plain integer. `sampling` and `fit`
1640            // are the rows ADR 0025 gives the element, by name.
1641            let id: i64 = t.get("id")?;
1642            let spec = with_refs(ui, |refs| parse_props(t, false, refs))?.spec;
1643            let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1644                match t.get::<Option<String>>(row)? {
1645                    None => Ok(0),
1646                    Some(s) => names
1647                        .iter()
1648                        .position(|n| *n == s)
1649                        .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1650                }
1651            };
1652            let sampling_names: Vec<&str> =
1653                kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1654            let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
1655            let opts = kui_core::ImageOpts {
1656                sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
1657                fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
1658            };
1659            ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
1660            Ok(())
1661        }
1662        "polygon" => {
1663            // `points`, each a `{x, y}` pair; the fill is the `bg` row, read
1664            // by parse_props like any node's (ADR 0025, decision 6).
1665            let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1666            let Some(list) = t.get::<Option<Table>>("points")? else {
1667                return Err(bad("polygon needs points"));
1668            };
1669            let points: Vec<kui_core::Vec2> = list
1670                .sequence_values::<mlua::Value>()
1671                .map(|v| {
1672                    let mlua::Value::Table(pt) = v? else {
1673                        return Err(bad("a polygon point is a {x, y} table"));
1674                    };
1675                    Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1676                })
1677                .collect::<mlua::Result<_>>()?;
1678            ui.core().open_from(p, Content::Polygon(&points));
1679            Ok(())
1680        }
1681        "line" => {
1682            // `from`/`to` or `points`, each point a `{x, y}` pair. `width`
1683            // parses as a sizing row too, harmlessly: the core overrides a
1684            // line's sizing with its own box. `color` is the text-colour
1685            // row, read off the parsed style, so it defaults to the
1686            // foreground like a text node's.
1687            let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1688            let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
1689                let mlua::Value::Table(pt) = v else {
1690                    return Err(bad("a line point is a {x, y} table"));
1691                };
1692                Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1693            };
1694            let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
1695                Some(list) => list
1696                    .sequence_values::<mlua::Value>()
1697                    .map(|v| point(v?))
1698                    .collect::<mlua::Result<_>>()?,
1699                None => {
1700                    let (Some(from), Some(to)) = (
1701                        t.get::<Option<mlua::Value>>("from")?,
1702                        t.get::<Option<mlua::Value>>("to")?,
1703                    ) else {
1704                        return Err(bad("line needs from and to, or points"));
1705                    };
1706                    vec![point(from)?, point(to)?]
1707                }
1708            };
1709            // A `$name` width is a length token; one that misses is the
1710            // default stroke, 1 px (AR14).
1711            let width = match t.get::<mlua::Value>("width")? {
1712                mlua::Value::Nil => None,
1713                v => with_refs(ui, |refs| length_of(&v, refs))?,
1714            }
1715            .unwrap_or(1.0);
1716            // No `color` is the theme's foreground, as for a text run.
1717            let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
1718            let mut stroke = kui_core::Stroke::new(width, stroke_color);
1719            stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
1720            ui.core().open_from(p, Content::Line(&points, stroke));
1721            Ok(())
1722        }
1723        "cells" => {
1724            // A string per row in `lines`, cells past a row's end blank;
1725            // `runs` of {row, col, len, fg, bg, flags, ul} colour and
1726            // attribute spans over them (0 keeps the default; `ul` is the
1727            // underline's own colour, backlog K4). The style rows size
1728            // the cells; the node rows are the node's.
1729            let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1730            let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
1731            let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
1732            if rows == 0 || cols == 0 {
1733                return Err(bad("cells needs rows and cols"));
1734            }
1735            let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
1736            let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
1737            if let Some(lines) = t.get::<Option<Table>>("lines")? {
1738                for (r, line) in lines.sequence_values::<String>().enumerate() {
1739                    if r >= rows {
1740                        break;
1741                    }
1742                    for (c, ch) in line?.chars().take(cols).enumerate() {
1743                        cells[r * cols + c].ch = ch;
1744                    }
1745                }
1746            }
1747            if let Some(runs) = t.get::<Option<Table>>("runs")? {
1748                for run in runs.sequence_values::<Table>() {
1749                    let run = run?;
1750                    let row: usize = run.get(1)?;
1751                    let col: usize = run.get(2)?;
1752                    let len: usize = run.get(3)?;
1753                    let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
1754                    let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
1755                    let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
1756                    let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
1757                    if row >= rows {
1758                        continue;
1759                    }
1760                    for c in col..(col + len).min(cols) {
1761                        let cell = &mut cells[row * cols + c];
1762                        if fg != 0 {
1763                            cell.fg = fg;
1764                        }
1765                        if bg != 0 {
1766                            cell.bg = bg;
1767                        }
1768                        cell.flags |= flags;
1769                        if ul != 0 {
1770                            cell.ul = ul;
1771                        }
1772                    }
1773                }
1774            }
1775            let cursor = match t.get::<Option<Table>>("cursor_at")? {
1776                Some(cur) => {
1777                    // An unknown name is refused, as Node refuses it —
1778                    // not folded to a block (backlog AR40).
1779                    let shape = match t.get::<Option<String>>("cursor_shape")? {
1780                        None => kui_core::CellCursor::Block,
1781                        Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
1782                            bad(format!(
1783                                "cursor_shape must be {}, not {s:?}",
1784                                kui_core::CellCursor::NAMES.join(" | ")
1785                            ))
1786                        })?,
1787                    };
1788                    let color = match t.get::<mlua::Value>("cursor_color")? {
1789                        mlua::Value::Nil => None,
1790                        v => with_refs(ui, |refs| parse_color(&v, refs))?,
1791                    }
1792                    .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
1793                    Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
1794                }
1795                None => None,
1796            };
1797            // The absolute line row 0 is; 0 when the app says nothing.
1798            let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
1799            let grid = kui_core::CellGrid {
1800                rows,
1801                cols,
1802                cells: &cells,
1803                style: p.style,
1804                cursor,
1805                origin_line,
1806            };
1807            ui.core().open_from(p, Content::Cells(&grid));
1808            Ok(())
1809        }
1810        "audio" => {
1811            // Handle from the host (kui_sound_add / Core::add_sound), passed
1812            // to scripts as a plain integer, like images.
1813            let src: i64 = t.get("src")?;
1814            let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
1815            if let Some(v) = t.get::<Option<f32>>("volume")? {
1816                spec = spec.volume(v);
1817            }
1818            if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
1819                spec = spec.looped();
1820            }
1821            spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
1822            if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
1823                spec = spec.finish();
1824            }
1825            if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
1826                spec.tag = Some(lua_to_value(&tag)?);
1827            }
1828            match t.get::<Option<String>>("key")? {
1829                Some(k) => ui.audio_keyed(&k, spec),
1830                None => ui.audio(spec),
1831            };
1832            Ok(())
1833        }
1834        "input" => {
1835            let label: String = t.get("label")?;
1836            let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
1837            widgets::text_input(ui, &label, &initial);
1838            Ok(())
1839        }
1840        "dropdown" => {
1841            // The stock select (`widgets::select_items`): the options are
1842            // strings or the row tables `env.open_menu` takes, read by the
1843            // core's one reader; `current` counts from 1.
1844            let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
1845                return Err(bad("dropdown needs a label (its key and accessible name)"));
1846            };
1847            let Some(options) = t.get::<Option<Table>>("options")? else {
1848                return Err(bad(
1849                    "dropdown needs options, a list of strings or menu rows",
1850                ));
1851            };
1852            let mut rows = lua_list_to_value(&options)?;
1853            if let Value::List(rows) = &mut rows {
1854                rows.iter_mut().for_each(alias_menu_role);
1855                // A key of a row table no row reads — `disabled` for
1856                // `enabled = false` — is dropped by the reader, so it is
1857                // reported as an unknown prop is (backlog RG10).
1858                if ui.core().diagnostics() {
1859                    for row in rows.iter() {
1860                        let Value::Map(fields) = row else { continue };
1861                        for (k, _) in fields.iter() {
1862                            if !kui_core::MenuItem::KEYS.contains(&k.as_str()) {
1863                                ui.core().warn(kui_core::diag::unknown_menu_item_key(k));
1864                            }
1865                        }
1866                    }
1867                }
1868            }
1869            let items =
1870                kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
1871            let current = match t.get::<Option<i64>>("current")? {
1872                None => None,
1873                Some(i) if i >= 1 => Some(i as usize - 1),
1874                Some(i) => {
1875                    return Err(bad(format!("dropdown current is an index from 1, not {i}")));
1876                }
1877            };
1878            widgets::select_items(ui, &label, &items, current);
1879            Ok(())
1880        }
1881        "edit" => {
1882            let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1883            let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
1884                Some(l) => l,
1885                None => return Err(bad("edit needs a key (state is retained by key)")),
1886            };
1887            let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
1888            let opts = EditOptions {
1889                style: p.style,
1890                multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
1891                autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
1892                wrap: p.wrap,
1893                ..Default::default()
1894            };
1895            ui.text_edit(&label, &initial, &opts, p.spec);
1896            Ok(())
1897        }
1898        "window_buttons" => {
1899            widgets::window_buttons(ui);
1900            Ok(())
1901        }
1902        "menu_bar" => {
1903            widgets::menu_bar(ui, menu_bar_of(t)?);
1904            Ok(())
1905        }
1906        "latency_graph" => {
1907            widgets::latency_graph(ui);
1908            Ok(())
1909        }
1910        "latency_hud" => {
1911            let (mut x, mut y) = (Align::End, Align::End);
1912            if let Some(at) = t.get::<Option<Table>>("at")? {
1913                x = parse_align(&at.get::<String>(1)?)?;
1914                y = parse_align(&at.get::<String>(2)?)?;
1915            }
1916            widgets::latency_hud_at(ui, x, y);
1917            Ok(())
1918        }
1919        "button" => {
1920            // `label` is the accessible name, and the text too unless
1921            // `text` says otherwise — the one string a script always gave
1922            // its button is the row a reader hears first. The other rows
1923            // the stock button admits (`schema::BUTTON_ROWS_LUA`) are read
1924            // by name over `widgets::button_spec`, as the JSX encoder and
1925            // `kui_button_with` read them: the look stays the widget's.
1926            // The tooltip goes first so an explicit `description` wins
1927            // over the shorthand, as it does in C — a table has no order
1928            // to make "the later one" mean anything.
1929            let label: String = t.get("label")?;
1930            let text: String = t
1931                .get::<Option<String>>("text")?
1932                .unwrap_or_else(|| label.clone());
1933            let key: String = t
1934                .get::<Option<String>>("key")?
1935                .unwrap_or_else(|| label.clone());
1936            let payload = match t.get::<Option<mlua::Value>>("on_click")? {
1937                Some(v) => lua_to_value(&v)?,
1938                None => Value::Null,
1939            };
1940            let mut out = PropsOut::new();
1941            out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
1942                .on_click(payload)
1943                .label(label.as_str());
1944            if let Some(hint) = t.get::<Option<String>>("tooltip")? {
1945                out.apply_tooltip(&hint);
1946            }
1947            with_refs(ui, |refs| {
1948                for name in ["description", "disabled", "accent"] {
1949                    let v = t.get::<mlua::Value>(name)?;
1950                    if v.is_nil() {
1951                        continue;
1952                    }
1953                    let def = schema::by_snake_name(name).expect("a button row");
1954                    if let Some(parsed) =
1955                        parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
1956                    {
1957                        schema::apply(def, parsed, &mut out).map_err(bad)?;
1958                    }
1959                }
1960                Ok(())
1961            })?;
1962            // An `index` keys the button by its row, as it does a box
1963            // (backlog AR40): declared beside `key`, the index wins.
1964            match t.get::<Option<u64>>("index")? {
1965                Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
1966                None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
1967            }
1968            Ok(())
1969        }
1970        "checkbox" | "radio" | "switch" => {
1971            // The button's shape (docs/adr/0034): `label` is the name and
1972            // the text unless `text` says otherwise, and the rows the
1973            // toggle admits (`schema::TOGGLE_ROWS_LUA`) are read by name
1974            // over `widgets::toggle_spec`.
1975            let kind = match ty {
1976                "checkbox" => widgets::Toggle::Checkbox,
1977                "radio" => widgets::Toggle::Radio,
1978                _ => widgets::Toggle::Switch,
1979            };
1980            let label: String = t.get("label")?;
1981            let text: String = t
1982                .get::<Option<String>>("text")?
1983                .unwrap_or_else(|| label.clone());
1984            let key: String = t
1985                .get::<Option<String>>("key")?
1986                .unwrap_or_else(|| label.clone());
1987            let payload = match t.get::<Option<mlua::Value>>("on_click")? {
1988                Some(v) => lua_to_value(&v)?,
1989                None => Value::Null,
1990            };
1991            let mut out = PropsOut::new();
1992            out.spec = widgets::toggle_spec(&ui.metrics())
1993                .on_click(payload)
1994                .label(label.as_str());
1995            if let Some(hint) = t.get::<Option<String>>("tooltip")? {
1996                out.apply_tooltip(&hint);
1997            }
1998            apply_named_rows(
1999                ui,
2000                t,
2001                &["description", "disabled", "checked", "mixed"],
2002                &mut out,
2003            )?;
2004            widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2005            Ok(())
2006        }
2007        "slider" => {
2008            // Keyed by `label`, which is its name too; the value rows, its
2009            // change tag and its width are read by name over
2010            // `widgets::slider_spec` (`schema::SLIDER_ROWS_LUA`).
2011            let label: String = t.get("label")?;
2012            let key: String = t
2013                .get::<Option<String>>("key")?
2014                .unwrap_or_else(|| label.clone());
2015            let mut out = PropsOut::new();
2016            out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2017            if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2018                out.apply_tooltip(&hint);
2019            }
2020            apply_named_rows(
2021                ui,
2022                t,
2023                &[
2024                    "description",
2025                    "disabled",
2026                    "value_now",
2027                    "value_min",
2028                    "value_max",
2029                    "value_step",
2030                    "value_text",
2031                    "on_change",
2032                    "width",
2033                    "min_width",
2034                    "max_width",
2035                ],
2036                &mut out,
2037            )?;
2038            widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2039            Ok(())
2040        }
2041        other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2042    }
2043}
2044
2045/// Reads the rows `names` off `t` by their Lua names and applies them over
2046/// `out` through the schema, the way the stock button reads its rows: a
2047/// widget whose look is its spec takes a closed list of rows, never the
2048/// whole prop list.
2049fn apply_named_rows(
2050    ui: &mut Ui<'_>,
2051    t: &Table,
2052    names: &[&str],
2053    out: &mut PropsOut,
2054) -> mlua::Result<()> {
2055    with_refs(ui, |refs| {
2056        for name in names {
2057            let v = t.get::<mlua::Value>(*name)?;
2058            if v.is_nil() {
2059                continue;
2060            }
2061            let def = schema::by_snake_name(name).expect("a schema row");
2062            if let Some(parsed) =
2063                parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2064            {
2065                schema::apply(def, parsed, out).map_err(bad)?;
2066            }
2067        }
2068        Ok(())
2069    })
2070}
2071
2072struct SpanPart {
2073    text: String,
2074    bold: bool,
2075    italic: bool,
2076    underline: bool,
2077    /// `underline_color` / `underline_style` (backlog K4); either implies
2078    /// `underline`.
2079    underline_color: Option<Color>,
2080    underline_style: Option<kui_core::UnderlineStyle>,
2081    strikethrough: bool,
2082    color: Option<Color>,
2083    bg: Option<Color>,
2084    /// `bg_radius`: the background rounded, one shape with the ones it
2085    /// meets (backlog F101).
2086    bg_radius: f32,
2087}
2088
2089fn span_of(p: &SpanPart) -> Span<'_> {
2090    let mut s = Span::new(&p.text);
2091    if p.bold {
2092        s = s.bold();
2093    }
2094    if p.italic {
2095        s = s.italic();
2096    }
2097    if p.underline {
2098        s = s.underline();
2099    }
2100    if let Some(c) = p.underline_color {
2101        s = s.underline_color(c);
2102    }
2103    if let Some(st) = p.underline_style {
2104        s = s.underline_style(st);
2105    }
2106    if p.strikethrough {
2107        s = s.strikethrough();
2108    }
2109    if let Some(c) = p.color {
2110        s = s.color(c);
2111    }
2112    if let Some(c) = p.bg {
2113        s = s.bg(c);
2114    }
2115    if p.bg_radius > 0.0 {
2116        s = s.bg_radius(p.bg_radius);
2117    }
2118    s
2119}
2120
2121/// `{ "plain", { "styled", bold = true, italic = true, color = 0x.. }, ... }`
2122fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2123    let mut out = Vec::new();
2124    for item in spans.sequence_values::<mlua::Value>() {
2125        match item? {
2126            mlua::Value::String(s) => out.push(SpanPart {
2127                text: s.to_str()?.to_string(),
2128                bold: false,
2129                italic: false,
2130                underline: false,
2131                underline_color: None,
2132                underline_style: None,
2133                strikethrough: false,
2134                color: None,
2135                bg: None,
2136                bg_radius: 0.0,
2137            }),
2138            mlua::Value::Table(t) => {
2139                let text: String = t
2140                    .get::<Option<String>>(1)?
2141                    .ok_or_else(|| bad("span table needs its text at [1]"))?;
2142                let color = match t.get::<mlua::Value>("color")? {
2143                    mlua::Value::Nil => None,
2144                    v => parse_color(&v, refs)?,
2145                };
2146                let bg = match t.get::<mlua::Value>("bg")? {
2147                    mlua::Value::Nil => None,
2148                    v => parse_color(&v, refs)?,
2149                };
2150                let underline_color = match t.get::<mlua::Value>("underline_color")? {
2151                    mlua::Value::Nil => None,
2152                    v => parse_color(&v, refs)?,
2153                };
2154                let underline_style = match t.get::<Option<String>>("underline_style")? {
2155                    None => None,
2156                    Some(name) => Some(
2157                        kui_core::UnderlineStyle::NAMES
2158                            .iter()
2159                            .position(|n| *n == name)
2160                            .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2161                            .ok_or_else(|| {
2162                                bad(format!(
2163                                    "underline_style must be one of {}, got {name:?}",
2164                                    kui_core::UnderlineStyle::NAMES.join(" | ")
2165                                ))
2166                            })?,
2167                    ),
2168                };
2169                out.push(SpanPart {
2170                    text,
2171                    bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
2172                    italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
2173                    underline: t.get::<Option<bool>>("underline")?.unwrap_or(false),
2174                    underline_color,
2175                    underline_style,
2176                    strikethrough: t.get::<Option<bool>>("strikethrough")?.unwrap_or(false),
2177                    color,
2178                    bg,
2179                    bg_radius: t.get::<Option<f32>>("bg_radius")?.unwrap_or(0.0).max(0.0),
2180                });
2181            }
2182            other => {
2183                return Err(bad(format!(
2184                    "span must be a string or table, got {}",
2185                    other.type_name()
2186                )));
2187            }
2188        }
2189    }
2190    Ok(out)
2191}
2192
2193/// `{ colors = { name = colour | { light =, dark = } | { from =, ops = } },
2194/// lengths = { name = px } }` as a [`kui_core::Tokens`] (ADR 0027). Names
2195/// are sorted, since a Lua table's iteration order is not one a
2196/// declaration can promise and the index is only what `env.tokens` lists
2197/// them in. A colour with `from` is a derived token (ADR 0028), its `ops`
2198/// a list of `{ verb, … }` tuples; the values are declared first and the
2199/// derived ones after, each once every source it names is in — position
2200/// cannot carry that order here, so the name does. One whose source never
2201/// arrives goes in last as written, for the core to drop with
2202/// `unknown-token`; a cycle drops the same way, each naming the other.
2203pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2204    let mut out = kui_core::Tokens::new();
2205    for pair in t.pairs::<String, mlua::Value>() {
2206        let (k, _) = pair?;
2207        if k != "colors" && k != "lengths" {
2208            return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2209        }
2210    }
2211    if let Some(colors) = t.get::<Option<Table>>("colors")? {
2212        let mut entries: Vec<(String, mlua::Value)> = colors
2213            .pairs::<String, mlua::Value>()
2214            .collect::<mlua::Result<_>>()?;
2215        entries.sort_by(|a, b| a.0.cmp(&b.0));
2216        // Values first; a derived token waits with its recipe parsed.
2217        let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2218        for (name, v) in entries {
2219            out = match &v {
2220                mlua::Value::Table(t) if t.contains_key("from")? => {
2221                    let (from, ops) = parse_recipe(&name, t)?;
2222                    derived.push((name, from, ops));
2223                    out
2224                }
2225                mlua::Value::Table(halves) => {
2226                    let half = |k: &str| -> mlua::Result<Color> {
2227                        match halves.get::<mlua::Value>(k)? {
2228                            mlua::Value::Nil => Err(bad(format!(
2229                                "tokens.colors.{name}: needs both light and dark"
2230                            ))),
2231                            v => parse_color_value(&v),
2232                        }
2233                    };
2234                    out.color_themed(&name, half("light")?, half("dark")?)
2235                }
2236                v => out.color(
2237                    &name,
2238                    parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2239                ),
2240            };
2241        }
2242        // Then the derived, in name order among those whose sources are
2243        // all declared, until none can be; what is left goes in as is.
2244        while !derived.is_empty() {
2245            let ready = derived.iter().position(|(_, from, ops)| {
2246                let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2247                known(from)
2248                    && ops.iter().all(|op| match op {
2249                        kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2250                            known(c)
2251                        }
2252                        _ => true,
2253                    })
2254            });
2255            let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2256            out = out.derive(&name, &from, ops);
2257        }
2258    }
2259    if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2260        let mut entries: Vec<(String, mlua::Value)> = lengths
2261            .pairs::<String, mlua::Value>()
2262            .collect::<mlua::Result<_>>()?;
2263        entries.sort_by(|a, b| a.0.cmp(&b.0));
2264        for (name, v) in entries {
2265            let px = number(&v)
2266                .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2267            out = out.length(&name, px);
2268        }
2269    }
2270    Ok(out)
2271}
2272
2273/// A derived token's `{ from = "peach", ops = { { "lift", 0.3 }, … } }`:
2274/// the source name and the chain, each op a tuple in the array part — the
2275/// verb at `[1]`, a colour name at `[2]` for `mix` and `readable`, the
2276/// number last. `ops` may be one bare tuple, or absent for an alias.
2277fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2278    for pair in t.pairs::<String, mlua::Value>() {
2279        let (k, _) = pair?;
2280        if k != "from" && k != "ops" {
2281            return Err(bad(format!(
2282                "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2283            )));
2284        }
2285    }
2286    let from = match t.get::<mlua::Value>("from")? {
2287        mlua::Value::String(s) => s.to_str()?.to_string(),
2288        _ => {
2289            return Err(bad(format!(
2290                "tokens.colors.{name}.from names a colour token or role"
2291            )));
2292        }
2293    };
2294    let ops = match t.get::<mlua::Value>("ops")? {
2295        mlua::Value::Nil => Vec::new(),
2296        mlua::Value::Table(list) => {
2297            // A bare tuple starts with its verb; a list starts with a tuple.
2298            let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2299                mlua::Value::String(_) => vec![list],
2300                _ => list
2301                    .sequence_values::<Table>()
2302                    .collect::<mlua::Result<_>>()?,
2303            };
2304            tuples
2305                .iter()
2306                .map(|op| parse_color_op(name, op))
2307                .collect::<mlua::Result<_>>()?
2308        }
2309        _ => {
2310            return Err(bad(format!(
2311                "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2312            )));
2313        }
2314    };
2315    Ok((from, ops))
2316}
2317
2318fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2319    let verb = match op.get::<mlua::Value>(1)? {
2320        mlua::Value::String(s) => s.to_str()?.to_string(),
2321        _ => {
2322            return Err(bad(format!(
2323                "tokens.colors.{name}.ops: an op starts with its verb"
2324            )));
2325        }
2326    };
2327    let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2328        return Err(bad(format!(
2329            "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2330        )));
2331    };
2332    let arity = if takes_color { 3 } else { 2 };
2333    if op.raw_len() != arity {
2334        return Err(bad(format!(
2335            "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2336            if takes_color {
2337                "a colour and a number"
2338            } else {
2339                "one number"
2340            },
2341            if takes_color { "token, t" } else { "t" }
2342        )));
2343    }
2344    let color = if takes_color {
2345        match op.get::<mlua::Value>(2)? {
2346            mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2347            _ => {
2348                return Err(bad(format!(
2349                    "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2350                )));
2351            }
2352        }
2353    } else {
2354        None
2355    };
2356    let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2357        bad(format!(
2358            "tokens.colors.{name}.ops: {verb}'s number is a number"
2359        ))
2360    })?;
2361    Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2362}
2363
2364/// What a `$name` in a prop resolves through while a table is parsed
2365/// (ADR 0027): the core's lookup for the running origin — its own table
2366/// over the host's, the roles in front — and the names that did not
2367/// resolve, raised as `unknown-token` once the borrow is handed back
2368/// ([`with_refs`]). A slot whose name resolves to nothing is left out, so
2369/// it keeps its default — the theme's foreground for a text's `color`, a
2370/// fit width, the default text size — the way Node's encoder drops the
2371/// prop; not an explicit transparent or zero, which would hide the text
2372/// a typo was on. The core's [`kui_core::NameRefs`] since AR14: the one
2373/// miss policy, written once, that a keyframe stop and an entrance
2374/// resolve through in every binding.
2375pub type Refs<'a> = kui_core::NameRefs<'a>;
2376
2377/// Runs `f` with a [`Refs`] over the frame's lookup, then raises what did
2378/// not resolve. The lookup borrows the core for `f`'s duration and nothing
2379/// longer, so the caller can open the node it parsed right after.
2380fn with_refs<R>(
2381    ui: &mut Ui<'_>,
2382    f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2383) -> mlua::Result<R> {
2384    let (r, errors, families) = {
2385        let mut refs = Refs::new(ui.core().token_lookup());
2386        let r = f(&mut refs);
2387        (r, refs.take_missed(), refs.take_missed_families())
2388    };
2389    for e in errors {
2390        ui.core().warn_unknown_token(&e);
2391    }
2392    for name in families {
2393        ui.core().warn_unknown_family(&name);
2394    }
2395    r
2396}
2397
2398/// A `$name` if `v` is one.
2399fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2400    if let mlua::Value::String(s) = v
2401        && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2402    {
2403        return Ok(Some(name.to_string()));
2404    }
2405    Ok(None)
2406}
2407
2408/// A number, or a `$name` length token.
2409fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2410    if let Some(name) = reference(v)? {
2411        return Ok(refs.length(&name));
2412    }
2413    number(v)
2414        .map(Some)
2415        .ok_or_else(|| bad("expected a number or a \"$token\""))
2416}
2417
2418/// Table → props. Constructor-order specials first (`dir` from the node
2419/// type, `size` before any style prop), then the Lua-shaped composites,
2420/// then every schema row by its snake_case name. Unknown keys (`type`,
2421/// `value`, `label`, children) fall through. `refs` is what a `$name`
2422/// resolves through ([`with_refs`]).
2423pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2424    let mut out = PropsOut::new();
2425    if is_row {
2426        out.spec = kui_core::NodeSpec::row();
2427    }
2428    match t.get::<mlua::Value>("size")? {
2429        mlua::Value::Nil => {}
2430        v => {
2431            if let Some(size) = length_of(&v, refs)? {
2432                out.style = kui_core::TextStyle::new(size);
2433            }
2434        }
2435    }
2436    // `radius` sets all four corners, so it must land before any
2437    // `radius_tl`-style override — table iteration order is undefined.
2438    match t.get::<mlua::Value>("radius")? {
2439        mlua::Value::Nil => {}
2440        v => {
2441            if let Some(r) = length_of(&v, refs)? {
2442                out.with_spec(|s| s.radius(r));
2443            }
2444        }
2445    }
2446    // Overflow bits accumulate across the walk (`clip` and `scroll` are
2447    // separate keys) and are applied once, so nothing here has to know that
2448    // scrolling clips too.
2449    let mut overflow = 0;
2450    for pair in t.pairs::<mlua::Value, mlua::Value>() {
2451        let (k, v) = pair?;
2452        let mlua::Value::String(k) = k else { continue };
2453        let k = k.to_str()?;
2454        match k.as_ref() {
2455            "size" | "radius" => {}
2456            "pad" => {
2457                let pad = parse_pad(&v, refs)?;
2458                out.apply_pad(pad);
2459            }
2460            "border" => {
2461                let b = match v {
2462                    mlua::Value::Table(b) => b,
2463                    _ => return Err(bad("border must be a table {w=, color=}")),
2464                };
2465                let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
2466                let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
2467                    .unwrap_or(Color::TRANSPARENT);
2468                out.with_spec(|s| s.border(w, c));
2469            }
2470            "clip" => overflow |= bit(&v, kui_core::OVERFLOW_CLIP),
2471            "scroll_x" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_X),
2472            "scroll" | "scroll_y" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_Y),
2473            "float" => {
2474                let cfg = parse_float(&v)?;
2475                out.with_spec(|s| s.float(cfg));
2476            }
2477            "key_focus" => out.key_focus = truthy(&v),
2478            "key" => {
2479                let mlua::Value::String(s) = &v else {
2480                    return Err(bad("key must be a string"));
2481                };
2482                out.key = Some(s.to_str()?.to_string());
2483            }
2484            "index" => {
2485                let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2486                    return Err(bad("index must be a number (the row's data index)"));
2487                };
2488                out.index = Some(i.max(0.0) as u64);
2489            }
2490            "row_count" => {
2491                let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2492                    return Err(bad(
2493                        "row_count must be a number (how many indexed rows the list has)",
2494                    ));
2495                };
2496                out.row_count = Some(n.max(0.0) as u64);
2497            }
2498            "tooltip" => {
2499                let mlua::Value::String(s) = &v else {
2500                    return Err(bad("tooltip must be a string"));
2501                };
2502                out.apply_tooltip(s.to_str()?.as_ref());
2503            }
2504            name => {
2505                // `repeat` is a Lua keyword, so that row also answers to
2506                // CSS's own name for it (`schema::LUA_ALIASES`, which the
2507                // unknown-prop check reads too).
2508                let name = schema::lua_alias(name).unwrap_or(name);
2509                let Some(def) = schema::by_snake_name(name) else {
2510                    continue;
2511                };
2512                if let Some(parsed) =
2513                    parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2514                {
2515                    schema::apply(def, parsed, &mut out).map_err(bad)?;
2516                }
2517            }
2518        }
2519    }
2520    out.with_spec(|s| s.overflow_bits(overflow));
2521    Ok(out)
2522}
2523
2524fn truthy(v: &mlua::Value) -> bool {
2525    matches!(v, mlua::Value::Boolean(true))
2526}
2527
2528/// `bit` when the flag is on, for ORing an overflow mask together.
2529fn bit(v: &mlua::Value, bit: u32) -> u32 {
2530    if truthy(v) { bit } else { 0 }
2531}
2532
2533fn number(v: &mlua::Value) -> Option<f32> {
2534    match v {
2535        mlua::Value::Number(n) => Some(*n as f32),
2536        mlua::Value::Integer(n) => Some(*n as f32),
2537        _ => None,
2538    }
2539}
2540
2541/// One schema value from Lua, by kind. `None` = absent (a false flag).
2542fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
2543    Ok(Some(match kind {
2544        Kind::F32 => match length_of(v, refs)? {
2545            Some(px) => Parsed::F32(px),
2546            None => return Ok(None),
2547        },
2548        Kind::Color => match parse_color(v, refs)? {
2549            Some(c) => Parsed::Color(c),
2550            None => return Ok(None),
2551        },
2552        Kind::Flag => {
2553            if truthy(v) {
2554                Parsed::Flag
2555            } else {
2556                return Ok(None);
2557            }
2558        }
2559        Kind::Enum(names) => {
2560            let mlua::Value::String(s) = v else {
2561                return Err(bad(format!("expected one of {names:?}")));
2562            };
2563            Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
2564        }
2565        Kind::Sizing => match parse_sizing(v, refs)? {
2566            Some(s) => Parsed::Sizing(s),
2567            None => return Ok(None),
2568        },
2569        // A `$name` is a fixed clamp of that many px, as a sizing's is;
2570        // one that misses leaves the row at its default (AR14). A string
2571        // is `"fit"` (a min's) or a size expression, a table the same
2572        // expression as data (backlog F109).
2573        Kind::Min | Kind::Max => Parsed::Bound(match v {
2574            v if reference(v)?.is_some() => match length_of(v, refs)? {
2575                Some(px) => kui_core::Bound::Px(px),
2576                None => return Ok(None),
2577            },
2578            // An expression the full table refused leaves the row at its
2579            // default too, and the core warns (backlog RG93).
2580            mlua::Value::String(s) => {
2581                let s = s.to_str()?;
2582                let b = if matches!(kind, Kind::Min) {
2583                    schema::min_str(&s)
2584                } else {
2585                    schema::max_str(&s)
2586                };
2587                match kept(b)? {
2588                    Some(b) => b,
2589                    None => return Ok(None),
2590                }
2591            }
2592            mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
2593                Some(b) => b,
2594                None => return Ok(None),
2595            },
2596            v => kui_core::Bound::Px(
2597                number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
2598            ),
2599        }),
2600        Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
2601        Kind::Str => {
2602            let mlua::Value::String(s) = v else {
2603                return Err(bad("expected a string"));
2604            };
2605            Parsed::Str(s.to_str()?.to_string())
2606        }
2607        // A stock family or an installed one by name, registered as it is
2608        // parsed (ADR 0037).
2609        Kind::Family => {
2610            let mlua::Value::String(s) = v else {
2611                return Err(bad("expected a family name"));
2612            };
2613            Parsed::Family(refs.family(&s.to_str()?))
2614        }
2615        Kind::Resource => match v {
2616            mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
2617            mlua::Value::Number(n) => Parsed::Resource(*n as u64),
2618            _ => return Err(bad("expected a resource handle (integer)")),
2619        },
2620        // A `$name` in a stop resolves through the same refs as a prop's
2621        // and misses the same way (AR14).
2622        Kind::Keyframes => Parsed::Keyframes(
2623            kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
2624        ),
2625        Kind::Enter => {
2626            Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
2627        }
2628    }))
2629}
2630
2631/// `0xRRGGBBAA` integers or `"#hex"` strings — a value, never a reference:
2632/// what a token declaration holds.
2633fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
2634    match v {
2635        mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
2636        mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
2637        mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
2638        _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
2639    }
2640}
2641
2642/// A colour prop: a value, or a `"$name"` token reference.
2643fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
2644    if let Some(name) = reference(v)? {
2645        return Ok(refs.color(&name));
2646    }
2647    parse_color_value(v)
2648        .map(Some)
2649        .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
2650}
2651
2652/// A size expression as data (backlog F109), as the core reads one — with
2653/// a percentage spelled `{ pct = n }` at any depth, Lua's word: `percent`
2654/// was the first cut's, refused since (RG33), and a size table taking it
2655/// would bring it back one level down.
2656fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
2657    fn check(v: &Value) -> mlua::Result<()> {
2658        match v {
2659            Value::Map(m) => {
2660                for (k, x) in m {
2661                    if k == "percent" {
2662                        return Err(bad("a percentage is { pct = n } in Lua"));
2663                    }
2664                    check(x)?;
2665                }
2666                Ok(())
2667            }
2668            Value::List(xs) => xs.iter().try_for_each(check),
2669            _ => Ok(()),
2670        }
2671    }
2672    let value = lua_to_value(v)?;
2673    check(&value)?;
2674    Ok(value)
2675}
2676
2677/// `Some` of a size expression, `None` for one the full table refused
2678/// ([`kui_core::calc::is_full`]) — the prop left undeclared, as a
2679/// `$name` that misses is (backlog RG93) — and the error for a bad one.
2680fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
2681    match r {
2682        Ok(v) => Ok(Some(v)),
2683        Err(e) if kui_core::calc::is_full(&e) => Ok(None),
2684        Err(e) => Err(bad(e)),
2685    }
2686}
2687
2688fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
2689    if let Some(name) = reference(v)? {
2690        return Ok(refs.length(&name).map(Sizing::Fixed));
2691    }
2692    Ok(Some(match v {
2693        mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
2694        mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
2695        mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
2696            Some(s) => s,
2697            None => return Ok(None),
2698        },
2699        mlua::Value::Table(t) => {
2700            if let Some(p) = t.get::<Option<f32>>("pct")? {
2701                Sizing::Percent(p / 100.0)
2702            } else if let Some(f) = t.get::<Option<f32>>("grow")? {
2703                Sizing::Grow(f)
2704            } else {
2705                // A size expression as data (backlog F109):
2706                // `{ clamp = { 400, { pct = 80 }, 1000 } }`.
2707                match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
2708                    bad(format!(
2709                        "sizing table needs pct, grow or a size expression: {e}"
2710                    ))
2711                })? {
2712                    Some(s) => s,
2713                    None => return Ok(None),
2714                }
2715            }
2716        }
2717        _ => return Err(bad("invalid sizing value")),
2718    }))
2719}
2720
2721/// The `pad` prop as declared: a number is the all-round shorthand, a table
2722/// names any of the family (`x`, `y`, `l`, `r`, `t`, `b`). What a missing
2723/// edge falls back to is [`PadShorthand::resolve`]'s call.
2724fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
2725    match v {
2726        mlua::Value::Table(t) => {
2727            let mut edge = |k: &str| -> mlua::Result<Option<f32>> {
2728                match t.get::<mlua::Value>(k)? {
2729                    mlua::Value::Nil => Ok(None),
2730                    v => length_of(&v, refs),
2731                }
2732            };
2733            Ok(PadShorthand {
2734                all: edge("all")?,
2735                x: edge("x")?,
2736                y: edge("y")?,
2737                l: edge("l")?,
2738                r: edge("r")?,
2739                t: edge("t")?,
2740                b: edge("b")?,
2741            })
2742        }
2743        v => Ok(PadShorthand {
2744            all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
2745            ..PadShorthand::default()
2746        }),
2747    }
2748}
2749
2750fn parse_align(s: &str) -> mlua::Result<Align> {
2751    schema::enum_index(schema::ALIGNS, s)
2752        .map(schema::align_idx)
2753        .map_err(bad)
2754}
2755
2756/// A preset name, wherever Lua spells one: `float = "below"` and a float
2757/// table's `anchor`. The names and what each attaches to are core's.
2758fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
2759    FloatConfig::preset(name).ok_or_else(|| {
2760        bad(format!(
2761            "bad float preset '{name}' (one of {})",
2762            kui_core::FLOAT_PRESETS.join(" | ")
2763        ))
2764    })
2765}
2766
2767/// An `{ x, y }` attach point under `key`, or `None` when it is absent.
2768fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
2769    let Some(at) = f.get::<Option<Table>>(key)? else {
2770        return Ok(None);
2771    };
2772    Ok(Some((
2773        parse_align(&at.get::<String>(1)?)?,
2774        parse_align(&at.get::<String>(2)?)?,
2775    )))
2776}
2777
2778fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
2779    let f = match v {
2780        mlua::Value::String(s) => return float_preset(&s.to_str()?),
2781        mlua::Value::Table(f) => f,
2782        _ => return Err(bad("float must be a preset string or a table")),
2783    };
2784    // `self` is the name the other bindings use; `self_at` stays accepted
2785    // because Lua shipped with it.
2786    let self_at = match parse_attach(f, "self")? {
2787        Some(at) => Some(at),
2788        None => parse_attach(f, "self_at")?,
2789    };
2790    Ok(FloatConfig::build(
2791        match f.get::<Option<String>>("anchor")? {
2792            Some(name) => float_preset(&name)?,
2793            None => FloatConfig::parent(),
2794        },
2795        parse_attach(f, "at")?,
2796        self_at,
2797        f.get::<Option<f32>>("dx")?,
2798        f.get::<Option<f32>>("dy")?,
2799        f.get::<Option<bool>>("fit")?.unwrap_or(false),
2800        f.get::<Option<bool>>("clip")?.unwrap_or(false),
2801    ))
2802}
2803
2804// ---------------------------------------------------------------------------
2805// Value <-> Lua
2806
2807/// How deep a Lua value may nest before [`lua_to_value`] refuses it: a
2808/// table holding itself, or one nested past any payload a view means,
2809/// would otherwise recurse off the Rust stack and abort the process
2810/// before anything read the value (backlog RG95).
2811pub const MAX_VALUE_DEPTH: usize = 64;
2812
2813pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
2814    to_value(
2815        v,
2816        &mut ValuePath {
2817            depth: 0,
2818            seen: Vec::new(),
2819        },
2820    )
2821}
2822
2823/// Tables nested this deep before [`ValuePath`] records which they are.
2824/// No payload a view means is this deep, and a table that holds itself
2825/// passes it and is caught a lap of its cycle later: asking each table
2826/// its identity costs a size table per row per frame 57 ns, +2.7% on
2827/// kui-lua's `lua_1000_rows/table per frame`.
2828const VALUE_TRACKED_PAST: usize = 8;
2829
2830/// How deep [`to_value`] is, and past [`VALUE_TRACKED_PAST`] the tables
2831/// it is inside.
2832struct ValuePath {
2833    depth: usize,
2834    seen: Vec<*const std::ffi::c_void>,
2835}
2836
2837/// [`lua_to_value`] with where it is: a table met again on its own path
2838/// holds itself. A table met twice off the path (the same list under two
2839/// keys) is copied twice, as before.
2840fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
2841    Ok(match v {
2842        mlua::Value::Nil => Value::Null,
2843        mlua::Value::Boolean(b) => Value::Bool(*b),
2844        mlua::Value::Integer(i) => Value::Int(*i),
2845        mlua::Value::Number(n) => Value::Float(*n),
2846        mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
2847        mlua::Value::Table(t) => {
2848            let tracked = path.depth >= VALUE_TRACKED_PAST;
2849            if tracked {
2850                let at = t.to_pointer();
2851                if path.seen.contains(&at) {
2852                    return Err(bad("a table that holds itself cannot be a value"));
2853                }
2854                path.seen.push(at);
2855            }
2856            if path.depth >= MAX_VALUE_DEPTH {
2857                return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
2858            }
2859            path.depth += 1;
2860            let len = t.raw_len();
2861            let value = if len > 0 {
2862                let mut list = Vec::with_capacity(len);
2863                for item in t.sequence_values::<mlua::Value>() {
2864                    list.push(to_value(&item?, path)?);
2865                }
2866                Value::List(list)
2867            } else {
2868                let mut map = Vec::new();
2869                for pair in t.pairs::<String, mlua::Value>() {
2870                    let (k, v) = pair?;
2871                    map.push((k, to_value(&v, path)?));
2872                }
2873                Value::Map(map)
2874            };
2875            path.depth -= 1;
2876            if tracked {
2877                path.seen.pop();
2878            }
2879            value
2880        }
2881        other => {
2882            return Err(mlua::Error::runtime(format!(
2883                "cannot convert {} to event payload",
2884                other.type_name()
2885            )));
2886        }
2887    })
2888}
2889
2890pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
2891    Ok(match v {
2892        Value::Null => mlua::Value::Nil,
2893        Value::Bool(b) => mlua::Value::Boolean(*b),
2894        Value::Int(i) => mlua::Value::Integer(*i),
2895        Value::Float(f) => mlua::Value::Number(*f),
2896        Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
2897        Value::List(items) => {
2898            let t = lua.create_table_with_capacity(items.len(), 0)?;
2899            for (i, item) in items.iter().enumerate() {
2900                t.set(i + 1, value_to_lua(lua, item)?)?;
2901            }
2902            mlua::Value::Table(t)
2903        }
2904        Value::Map(entries) => {
2905            let t = lua.create_table_with_capacity(0, entries.len())?;
2906            for (k, v) in entries {
2907                t.set(k.as_str(), value_to_lua(lua, v)?)?;
2908            }
2909            mlua::Value::Table(t)
2910        }
2911    })
2912}
2913
2914#[cfg(test)]
2915mod tests {
2916    use super::*;
2917    use kui_core::{
2918        Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
2919        WindowButton, WindowId,
2920    };
2921
2922    #[test]
2923    fn value_round_trips_through_lua() {
2924        let lua = Lua::new();
2925        let original = Value::map([
2926            ("kind", "inc".into()),
2927            ("by", Value::Int(2)),
2928            (
2929                "weights",
2930                Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
2931            ),
2932            ("enabled", Value::Bool(true)),
2933        ]);
2934        let lua_v = value_to_lua(&lua, &original).unwrap();
2935        let back = lua_to_value(&lua_v).unwrap();
2936        assert_eq!(back.get_str("kind"), Some("inc"));
2937        assert_eq!(back.get_int("by"), Some(2));
2938        assert_eq!(back.get_bool("enabled"), Some(true));
2939        match back.get("weights") {
2940            Some(Value::List(items)) => assert_eq!(items.len(), 2),
2941            other => panic!("expected list, got {other:?}"),
2942        }
2943    }
2944
2945    /// A `Refs` over a bare core: no tokens declared, the roles resolve.
2946    /// Leaked on purpose — the lookup borrows the core, and a test parses
2947    /// one table and asserts, so a core per call is the simplest shape.
2948    fn test_refs() -> Refs<'static> {
2949        let core: &'static Core = Box::leak(Box::new(Core::new()));
2950        Refs::new(core.token_lookup())
2951    }
2952
2953    fn eval_table(lua: &Lua, src: &str) -> Table {
2954        lua.load(src).eval().unwrap()
2955    }
2956
2957    /// The whole schema surface from a Lua table equals the Rust builder.
2958    #[test]
2959    fn schema_props_match_the_rust_builder() {
2960        let lua = Lua::new();
2961        let t = eval_table(
2962            &lua,
2963            r##"{
2964                width = "grow", height = {pct = 50},
2965                min_width = 10, max_width = 500, min_height = 5, max_height = 300,
2966                pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
2967                main_align = "center", cross_align = "end", center = false,
2968                bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
2969                clip = true, scroll = true, scroll_x = true,
2970                float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
2971                         dx = -8, dy = -8, fit = true},
2972                hoverable = true, window = "close",
2973                on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
2974                modal = "dlg", on_context_menu = {kind = "menu"},
2975                initial_focus = true,
2976                key = "panel", key_focus = true,
2977            }"##,
2978        );
2979        let p = parse_props(&t, true, &mut test_refs()).unwrap();
2980        let expected = NodeSpec::row()
2981            .grow_width()
2982            .height(Sizing::Percent(0.5))
2983            .min_width(10.0)
2984            .max_width(500.0)
2985            .min_height(5.0)
2986            .max_height(300.0)
2987            .padding(Edges {
2988                l: 1.0,
2989                r: 2.0,
2990                t: 3.0,
2991                b: 4.0,
2992            })
2993            .gap(8.0)
2994            .main_align(Align::Center)
2995            .cross_align(Align::End)
2996            .bg(Color::hex(0x14161eff))
2997            .radius(6.0)
2998            .border(1.0, Color::hex(0x2a2d3aff))
2999            .clip()
3000            .scroll_y()
3001            .scroll_x()
3002            .float(
3003                FloatConfig::viewport()
3004                    .inside(Align::End, Align::End)
3005                    .offset(-8.0, -8.0)
3006                    .fit(),
3007            )
3008            .hoverable()
3009            .window_button(WindowButton::Close)
3010            .on_click(Value::map([("kind", "hit".into())]))
3011            .on_drag("d")
3012            .on_key(Value::Int(7))
3013            .key_up()
3014            .modal("dlg")
3015            .initial_focus()
3016            .on_context_menu(Value::map([("kind", "menu".into())]));
3017        assert_eq!(p.spec, expected);
3018        assert_eq!(p.key.as_deref(), Some("panel"));
3019        assert!(p.key_focus);
3020    }
3021
3022    /// A min is a number or `"fit"`, per axis, and nothing else: the
3023    /// sizing words a min cannot be are refused by name.
3024    #[test]
3025    fn a_min_is_a_number_or_fit() {
3026        let lua = Lua::new();
3027        let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3028        let p = parse_props(&t, false, &mut test_refs()).unwrap();
3029        let expected = NodeSpec::column()
3030            .min_width(kui_core::Min::FIT)
3031            .min_height(3.0);
3032        assert_eq!(p.spec, expected);
3033        let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3034        let err = parse_props(&t, false, &mut test_refs())
3035            .unwrap_err()
3036            .to_string();
3037        assert!(err.contains("bad min"), "{err}");
3038    }
3039
3040    /// The shapes the core decides on, spelled the Lua way: the pad family
3041    /// beyond `l/r/t/b`, `self` as the other bindings name it, and a preset
3042    /// as a base with one override — the untouched `dy` keeps below's gap.
3043    #[test]
3044    fn the_lua_composites_resolve_the_way_the_core_says() {
3045        let lua = Lua::new();
3046        let t = eval_table(
3047            &lua,
3048            r#"{ pad = { all = 4, x = 10, b = 1 },
3049                 float = { anchor = "below", dx = 6 } }"#,
3050        );
3051        let p = parse_props(&t, false, &mut test_refs()).unwrap();
3052        assert_eq!(
3053            p.spec.layout.padding,
3054            Edges {
3055                l: 10.0,
3056                r: 10.0,
3057                t: 4.0,
3058                b: 1.0,
3059            }
3060        );
3061        assert_eq!(
3062            p.spec.layout.float,
3063            Some(FloatConfig::build(
3064                FloatConfig::below(),
3065                None,
3066                None,
3067                Some(6.0),
3068                None,
3069                false,
3070                false
3071            ))
3072        );
3073
3074        // `self` and the `self_at` Lua shipped with name the same point.
3075        let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3076        let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3077        assert_eq!(
3078            parse_props(&by_self, false, &mut test_refs())
3079                .unwrap()
3080                .spec
3081                .layout
3082                .float,
3083            parse_props(&by_self_at, false, &mut test_refs())
3084                .unwrap()
3085                .spec
3086                .layout
3087                .float
3088        );
3089
3090        // An unknown preset names the ones that exist instead of silently
3091        // floating against the parent.
3092        let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3093        let e = parse_props(&bad_preset, false, &mut test_refs())
3094            .unwrap_err()
3095            .to_string();
3096        assert!(e.contains("beneath") && e.contains("below"), "{e}");
3097    }
3098
3099    #[test]
3100    fn text_style_props_match_the_rust_builder() {
3101        let lua = Lua::new();
3102        let t = eval_table(
3103            &lua,
3104            r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3105        );
3106        let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3107        assert_eq!(
3108            style,
3109            TextStyle::new(20.0)
3110                .line_height(30.0)
3111                .color(Color::hex(0x73d98cff))
3112                .family(FontFamily::Mono)
3113        );
3114        let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3115        assert_eq!(
3116            parse_props(&t, false, &mut test_refs()).unwrap().style,
3117            TextStyle::default().nowrap().max_lines(2).ellipsis()
3118        );
3119        // `size` is applied first regardless of table iteration order, so a
3120        // color set alongside it survives the TextStyle::new reset.
3121        let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3122        assert_eq!(
3123            parse_props(&t, false, &mut test_refs()).unwrap().style,
3124            TextStyle::new(12.0).color(Color::hex(0xffffffff))
3125        );
3126    }
3127
3128    #[test]
3129    fn bad_values_name_the_prop() {
3130        let lua = Lua::new();
3131        let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3132        let e = parse_props(&t, false, &mut test_refs())
3133            .unwrap_err()
3134            .to_string();
3135        assert!(e.contains("main_align"), "{e}");
3136        assert!(e.contains("middle"), "{e}");
3137    }
3138
3139    fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3140        let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3141        ui.set_origin(OriginId(1));
3142        ext.view(&Slot::root(), &mut ui).unwrap();
3143        ui.finish();
3144        core.output().0.quads.len()
3145    }
3146
3147    #[test]
3148    fn script_view_builds_ir_nodes() {
3149        let mut ext = LuaExtension::from_source(
3150            "test",
3151            r#"
3152                count = 41
3153                function view()
3154                  return column { gap = 8, pad = 16, bg = 0x10121aff,
3155                    text("count: " .. count, { size = 20 }),
3156                    button { label = "bump", on_click = { kind = "bump" } },
3157                  }
3158                end
3159                function on_event(ev)
3160                  if ev.kind == "bump" then count = count + 1 end
3161                end
3162            "#,
3163        )
3164        .unwrap();
3165
3166        let mut core = Core::new();
3167        let quads = frame(&mut core, &mut ext);
3168        // Panel bg + button bg + glyphs for two strings.
3169        assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3170
3171        // Events round-trip into Lua state.
3172        ext.on_event(&UiEvent {
3173            origin: OriginId(1),
3174            key: Key::ROOT,
3175            payload: Value::map([("kind", "bump".into())]),
3176            window: WindowId::MAIN,
3177            slot: None,
3178        });
3179        let count: i64 = ext.lua.globals().get("count").unwrap();
3180        assert_eq!(count, 42);
3181    }
3182
3183    /// `devtools_tab` (ADR 0032): the script's `view` is called only while
3184    /// its tab is on show, the tree it returns lands over the panel's tab
3185    /// body as the script's own nodes, the slot form declares without
3186    /// calling anything, and a declaration of neither form is the
3187    /// `bad-devtools-tab` warning rather than a build error.
3188    #[test]
3189    fn a_devtools_tab_calls_its_view_only_while_on_show() {
3190        let mut ext = LuaExtension::from_source(
3191            "test",
3192            r#"
3193                calls = 0
3194                function view(env)
3195                  return column { gap = 8,
3196                    text("app"),
3197                    devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3198                      calls = calls + 1
3199                      return column { text("from lua"),
3200                        button { label = "jump", on_click = { kind = "jump" } } }
3201                    end },
3202                    devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3203                    devtools_tab { name = "bad", label = "Bad" },
3204                  }
3205                end
3206            "#,
3207        )
3208        .unwrap();
3209        let mut core = Core::new();
3210        core.set_devtools(true);
3211        core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3212        core.set_inspect(true);
3213        frame(&mut core, &mut ext);
3214        frame(&mut core, &mut ext);
3215        let calls: i64 = ext.lua.globals().get("calls").unwrap();
3216        assert_eq!(calls, 0, "not on show: the view was not called");
3217        let ws = core.take_warnings();
3218        assert_eq!(
3219            ws.iter()
3220                .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3221                .count(),
3222            1,
3223            "the tab with neither form warns once: {ws:?}"
3224        );
3225        // Ctrl+Shift+N three times: tree, then Tree-sitter (the first
3226        // declared tab).
3227        let chord = || {
3228            kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3229                kui_core::KeyCode::Char('N'),
3230                kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3231            ))
3232        };
3233        core.handle_input(chord());
3234        core.handle_input(chord());
3235        frame(&mut core, &mut ext);
3236        let calls: i64 = ext.lua.globals().get("calls").unwrap();
3237        assert_eq!(calls, 1, "on show: called once a frame");
3238        frame(&mut core, &mut ext);
3239        let body = core
3240            .nodes()
3241            .iter()
3242            .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
3243            .map(|n| n.rect)
3244            .expect("the body");
3245        let jump = core
3246            .nodes()
3247            .iter()
3248            .find(|n| n.label.as_deref() == Some("jump"))
3249            .map(|n| n.rect)
3250            .expect("the script's button");
3251        assert!(
3252            jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
3253            "{jump:?} in {body:?}"
3254        );
3255        assert!(
3256            core.nodes()
3257                .iter()
3258                .any(|n| n.text.as_deref() == Some("from lua")),
3259            "the script's text painted"
3260        );
3261        let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
3262        assert_eq!(evs.len(), 1);
3263        assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
3264        assert_eq!(evs[0].kind(), Some("jump"));
3265    }
3266
3267    /// The root table's `windows` list is `Ui::window` per entry: a name
3268    /// alone takes the defaults, a table its own size, and the commands
3269    /// the declared set produces come out of the core for the host to
3270    /// drain — this binding opens nothing itself.
3271    #[test]
3272    fn the_root_table_declares_windows() {
3273        use kui_core::{WindowCommand, WindowConfig};
3274        let mut ext = LuaExtension::from_source(
3275            "windows",
3276            r#"
3277                function view(env)
3278                  return column {
3279                    windows = { { name = "palette", width = 400, height = 300,
3280                                  activates = false }, "tools" },
3281                    text("main"),
3282                  }
3283                end
3284            "#,
3285        )
3286        .unwrap();
3287        let mut core = Core::new();
3288        frame(&mut core, &mut ext);
3289        let cmds = core.take_window_commands();
3290        assert_eq!(cmds.len(), 2, "{cmds:?}");
3291        assert_eq!(
3292            cmds[0],
3293            WindowCommand::Open {
3294                id: WindowId(1),
3295                owner: WindowId::MAIN,
3296                origin: OriginId(1),
3297                config: WindowConfig {
3298                    size: Size::new(400.0, 300.0),
3299                    activates: false,
3300                    ..WindowConfig::default()
3301                },
3302            }
3303        );
3304        assert_eq!(
3305            cmds[1],
3306            WindowCommand::Open {
3307                id: WindowId(2),
3308                owner: WindowId::MAIN,
3309                origin: OriginId(1),
3310                config: WindowConfig::default(),
3311            }
3312        );
3313        // Declared again: nothing new, and no unknown-prop line for the key.
3314        frame(&mut core, &mut ext);
3315        assert!(core.take_window_commands().is_empty());
3316        assert!(core.take_warnings().is_empty());
3317    }
3318
3319    /// `kind = "popup"` is ADR 0004 decision 9's menu surface: the anchor
3320    /// rides through untouched, and it does not activate unless asked —
3321    /// a popup that takes OS focus blurs the field that opened it.
3322    #[test]
3323    fn a_windows_entry_declares_a_popup() {
3324        use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
3325        let mut ext = LuaExtension::from_source(
3326            "windows-popup",
3327            r#"
3328                function view(env)
3329                  return column {
3330                    windows = { { name = "menu", kind = "popup",
3331                                  width = 160, height = 320,
3332                                  anchor = { x = 12, y = 40, w = 160, h = 24 } } },
3333                    text("main"),
3334                  }
3335                end
3336            "#,
3337        )
3338        .unwrap();
3339        let mut core = Core::new();
3340        frame(&mut core, &mut ext);
3341        assert_eq!(
3342            core.take_window_commands(),
3343            vec![WindowCommand::Open {
3344                id: WindowId(1),
3345                owner: WindowId::MAIN,
3346                origin: OriginId(1),
3347                config: WindowConfig {
3348                    kind: WindowKind::Popup,
3349                    size: Size::new(160.0, 320.0),
3350                    activates: false,
3351                    anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
3352                },
3353            }]
3354        );
3355    }
3356
3357    /// A kind kui does not have is refused where it is written rather than
3358    /// dropped: opening a normal window for it would read as the popup
3359    /// having worked. (C cannot do this — an integer field has no room to
3360    /// refuse in — so it warns `unknown-window-kind` a frame later.)
3361    #[test]
3362    fn a_windows_entry_cannot_name_an_unknown_kind() {
3363        let mut ext = LuaExtension::from_source(
3364            "windows-kind",
3365            r#"
3366                function view(env)
3367                  return column {
3368                    windows = { { name = "palette", kind = "sheet" } },
3369                    text("main"),
3370                  }
3371                end
3372            "#,
3373        )
3374        .unwrap();
3375        let mut core = Core::new();
3376        let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3377        ui.set_origin(OriginId(1));
3378        let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
3379        assert!(err.contains("sheet"), "{err}");
3380        assert!(err.contains("popup"), "{err}");
3381    }
3382
3383    /// A stock button takes `index` as a box does, and the index wins over
3384    /// its label; a `cells` cursor with a shape nobody has is refused
3385    /// rather than folded to a block (backlog AR40).
3386    #[test]
3387    fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
3388        let mut ext = LuaExtension::from_source(
3389            "rows",
3390            r#"
3391                from = 0
3392                function view(env)
3393                  return column {
3394                    button { label = "Open", index = from, on_click = { row = from } },
3395                    button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
3396                    button { label = "Keyed", key = "named", index = 7, on_click = "k" },
3397                  }
3398                end
3399            "#,
3400        )
3401        .unwrap();
3402        let mut core = Core::new();
3403        core.set_diagnostics(true);
3404        let frame = |core: &mut Core, ext: &mut LuaExtension| {
3405            let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3406            ui.set_origin(OriginId(1));
3407            ext.view(&Slot::root(), &mut ui).unwrap();
3408            ui.finish();
3409        };
3410        frame(&mut core, &mut ext);
3411        assert!(core.take_warnings().is_empty(), "index is a button row");
3412        let buttons = |core: &mut Core| -> Vec<Key> {
3413            core.access_tree()
3414                .nodes
3415                .iter()
3416                .filter(|n| n.role == kui_core::Role::Button)
3417                .map(|n| n.key)
3418                .collect()
3419        };
3420        let before = buttons(&mut core);
3421        assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
3422        assert!(
3423            core.key_of("named").is_none(),
3424            "declared beside `key`, the index wins"
3425        );
3426        ext.lua.globals().set("from", 1).unwrap();
3427        frame(&mut core, &mut ext);
3428        let after = buttons(&mut core);
3429        assert_eq!(
3430            after[0], before[1],
3431            "row 1 keeps its key as it moves up the list"
3432        );
3433
3434        let mut ext = LuaExtension::from_source(
3435            "term",
3436            r#"
3437                function view(env)
3438                  return column { cells { key = "term", rows = 1, cols = 4, size = 14,
3439                    lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
3440                end
3441            "#,
3442        )
3443        .unwrap();
3444        let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3445        ui.set_origin(OriginId(1));
3446        let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3447        assert!(
3448            err.contains("block | bar | underline") && err.contains("blob"),
3449            "{err}"
3450        );
3451    }
3452
3453    /// The root's `option_as_alt` names a side (backlog F113), and a name
3454    /// kui does not have is refused with the four it does.
3455    #[test]
3456    fn option_as_alt_is_a_side_by_name() {
3457        let mut core = Core::new();
3458        let mut ext = LuaExtension::from_source(
3459            "keys",
3460            r#"
3461                function view(env)
3462                  return column { option_as_alt = "left", text("x") }
3463                end
3464            "#,
3465        )
3466        .unwrap();
3467        frame(&mut core, &mut ext);
3468        assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3469
3470        let mut ext = LuaExtension::from_source(
3471            "keys",
3472            r#"
3473                function view(env)
3474                  return column { option_as_alt = "meta", text("x") }
3475                end
3476            "#,
3477        )
3478        .unwrap();
3479        let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3480        ui.set_origin(OriginId(1));
3481        let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3482        assert!(
3483            err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
3484            "{err}"
3485        );
3486    }
3487
3488    /// A value that holds itself, or nests past [`MAX_VALUE_DEPTH`],
3489    /// is an error, not a stack overflow (backlog RG95); one table under
3490    /// two keys is still two copies.
3491    #[test]
3492    fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
3493        let lua = Lua::new();
3494        let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
3495        let err = |src: &str| read(src).unwrap_err().to_string();
3496        assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
3497        assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
3498        let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
3499        assert_eq!(shared.get("a"), shared.get("b"));
3500        let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
3501        assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
3502        assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
3503        // A handler's message crosses the same function.
3504        assert!(err(&nested(100_000)).contains("nested past 64"));
3505    }
3506
3507    /// A view node that is its own ancestor, or a view nested past
3508    /// [`MAX_VIEW_DEPTH`], fails the view rather than the process
3509    /// (backlog RG95); the deepest one taken builds.
3510    #[test]
3511    fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
3512        let run = |body: &str| {
3513            let mut core = Core::new();
3514            let mut ext =
3515                LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
3516                    .unwrap();
3517            let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3518            ui.set_origin(OriginId(1));
3519            let built = ext.view(&Slot::root(), &mut ui);
3520            ui.finish();
3521            built
3522        };
3523        let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
3524        assert!(err.contains("holds itself"), "{err}");
3525        let nested = |n: usize| {
3526            format!(
3527                "local t = text('x')
3528                 for i = 2, {n} do
3529                   if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
3530                   elseif i % 3 == 0 then t = tooltip {{ t }}
3531                   else t = column {{ t }} end
3532                 end
3533                 return t"
3534            )
3535        };
3536        run(&nested(MAX_VIEW_DEPTH)).unwrap();
3537        let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
3538        assert!(err.contains("nested past 128"), "{err}");
3539        // The path empties on the error: the next view builds.
3540        run(&nested(8)).unwrap();
3541    }
3542
3543    /// `"none"` declares nothing (backlog RG84): a host's side survives a
3544    /// script that writes its own setting through as `"none"`, as it does
3545    /// a Node view's `optionAsAlt: 'none'`.
3546    #[test]
3547    fn option_as_alt_none_leaves_the_hosts_side() {
3548        let mut core = Core::new();
3549        let mut ext = LuaExtension::from_source(
3550            "keys",
3551            r#"
3552                function view(env)
3553                  return column { option_as_alt = "none", text("x") }
3554                end
3555            "#,
3556        )
3557        .unwrap();
3558        let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3559        ui.option_as_alt(kui_core::OptionAsAlt::Left);
3560        ui.set_origin(OriginId(1));
3561        ext.view(&Slot::root(), &mut ui).unwrap();
3562        ui.finish();
3563        assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3564    }
3565
3566    /// Every node type the prelude offers lowers without error and draws.
3567    #[test]
3568    fn every_node_type_lowers() {
3569        let mut ext = LuaExtension::from_source(
3570            "all",
3571            r##"
3572                function view(env)
3573                  return column { gap = 4, window_title = "all nodes", always_on_top = true,
3574                    secure_input = true, option_as_alt = "right",
3575                    titlebar { text("custom title"), window_buttons() },
3576                    titlebar { title = "plain title" },
3577                    text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
3578                           { " — flatter", italic = true } }, { size = 13 }),
3579                    edit { key = "note", initial = "hello", size = 14, width = 200,
3580                           multiline = true },
3581                    input { label = "name", initial = "" },
3582                    dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
3583                    row { tooltip = "hover hint", pad = 4, text("badge") },
3584                    row { hoverable = true, text("legend"), tooltip("always shown") },
3585                    row { text("rich tip"), tooltip { text("a"), text("b") } },
3586                    latency_graph(),
3587                    latency_hud { at = { "start", "end" } },
3588                    button { label = "ok", on_click = "ok" },
3589                  }
3590                end
3591            "##,
3592        )
3593        .unwrap();
3594        let mut core = Core::new();
3595        let quads = frame(&mut core, &mut ext);
3596        assert!(quads > 60, "got {quads} quads");
3597        assert_eq!(core.window_title(), Some("all nodes"));
3598        assert!(core.always_on_top());
3599        assert!(core.secure_input());
3600        assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
3601        // And every key above is one some table claims: this scene is the
3602        // allow-list's fixture, so a new element prop that nobody adds to
3603        // `ELEMENTS.lua_own` fails here instead of warning at a user.
3604        let unknown: Vec<String> = core
3605            .take_warnings()
3606            .into_iter()
3607            .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3608            .map(|w| w.message)
3609            .collect();
3610        assert!(unknown.is_empty(), "{unknown:#?}");
3611    }
3612
3613    /// A key no table claims is thrown on the floor by the binding — so it
3614    /// says so, once, in the spelling Lua actually takes.
3615    #[test]
3616    fn unknown_props_warn_once_in_lua_spelling() {
3617        let mut ext = LuaExtension::from_source(
3618            "typos",
3619            r#"
3620                function view(env)
3621                  return column { pad = 8,
3622                    row { hoverBg = 0x333333ff, width = 10, height = 10 },
3623                    row { hoverBg = 0x333333ff, width = 10, height = 10 },
3624                    row { colour = 0x333333ff, width = 10, height = 10 },
3625                  }
3626                end
3627            "#,
3628        )
3629        .unwrap();
3630        let mut core = Core::new();
3631        frame(&mut core, &mut ext);
3632        let mut warned: Vec<String> = core
3633            .take_warnings()
3634            .into_iter()
3635            .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3636            .map(|w| w.message)
3637            .collect();
3638        warned.sort();
3639        assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
3640        assert!(
3641            warned[1].contains("`hoverBg` is not a prop of box")
3642                && warned[1].contains("did you mean `hover_bg`?"),
3643            "{warned:#?}"
3644        );
3645        // Nothing near `colour`, so no guess is offered.
3646        assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
3647        // The second frame is silent: (code, key) dedup, as for every check.
3648        frame(&mut core, &mut ext);
3649        assert!(core.take_warnings().is_empty());
3650    }
3651
3652    /// AR13: a text reads its style rows and nothing else — `live`,
3653    /// `label`, `on_click`, `key` on one reach no tree and used to be
3654    /// dropped silently; they warn now, naming the rows a text does read.
3655    #[test]
3656    fn a_text_warns_about_the_rows_it_does_not_read() {
3657        let mut ext = LuaExtension::from_source(
3658            "textrows",
3659            r#"
3660                function view(env)
3661                  return column { pad = 8,
3662                    text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
3663                    text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
3664                  }
3665                end
3666            "#,
3667        )
3668        .unwrap();
3669        let mut core = Core::new();
3670        frame(&mut core, &mut ext);
3671        let mut warned: Vec<String> = core
3672            .take_warnings()
3673            .into_iter()
3674            .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3675            .map(|w| w.message)
3676            .collect();
3677        warned.sort();
3678        assert_eq!(warned.len(), 3, "{warned:#?}");
3679        for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
3680            assert!(
3681                w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
3682                "{w}"
3683            );
3684            assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
3685        }
3686    }
3687
3688    /// `direction` is the Lua spelling of the `repeat` row (a Lua keyword),
3689    /// and the check reads the same alias table the parser remaps through.
3690    #[test]
3691    fn the_repeat_alias_does_not_warn() {
3692        let mut ext = LuaExtension::from_source(
3693            "alias",
3694            r#"
3695                function view(env)
3696                  return column {
3697                    row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
3698                          transition = 100, direction = "alternate" },
3699                  }
3700                end
3701            "#,
3702        )
3703        .unwrap();
3704        let mut core = Core::new();
3705        frame(&mut core, &mut ext);
3706        assert!(core.take_warnings().is_empty());
3707    }
3708
3709    /// The check is behind the same gate as every other diagnostic.
3710    #[test]
3711    fn unknown_props_stay_quiet_with_diagnostics_off() {
3712        let mut ext = LuaExtension::from_source(
3713            "quiet",
3714            r#"
3715                function view(env)
3716                  return column { hoverBg = 0x333333ff }
3717                end
3718            "#,
3719        )
3720        .unwrap();
3721        let mut core = Core::new();
3722        core.set_diagnostics(false);
3723        frame(&mut core, &mut ext);
3724        assert!(core.take_warnings().is_empty());
3725    }
3726
3727    /// `tooltip = "hint"` on a container makes it hoverable and floats the
3728    /// hint only while the cursor is over it.
3729    #[test]
3730    fn tooltip_prop_is_hover_gated() {
3731        let mut ext = LuaExtension::from_source(
3732            "tip",
3733            r#"
3734                function view(env)
3735                  return column { pad = 10,
3736                    row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
3737                  }
3738                end
3739            "#,
3740        )
3741        .unwrap();
3742        let mut core = Core::new();
3743        let idle = frame(&mut core, &mut ext);
3744        core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
3745        let hovered = frame(&mut core, &mut ext);
3746        assert!(
3747            hovered > idle + 5,
3748            "hover should add the tooltip's quads ({idle} -> {hovered})"
3749        );
3750        core.handle_input(InputEvent::CursorLeft);
3751        assert_eq!(frame(&mut core, &mut ext), idle);
3752    }
3753
3754    /// `role` / `label` / `checked` / `selected` / `expanded` / `value_*`
3755    /// The stock button reads the access rows and nothing else
3756    /// (`schema::BUTTON_ROWS_LUA`): `label` is the name and the text
3757    /// unless `text` says otherwise, `tooltip` is the description, and a
3758    /// row it would drop is warned about with the rows it does read.
3759    #[test]
3760    fn the_stock_button_admits_the_access_rows() {
3761        let mut ext = LuaExtension::from_source(
3762            "button",
3763            r#"
3764                function view(env)
3765                  return column { pad = 10, gap = 4,
3766                    button { label = "go", on_click = "go", description = "Starts the run" },
3767                    button { label = "Stop the run", text = "stop", on_click = "stop",
3768                             disabled = true, tooltip = "Nothing is running" },
3769                    button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
3770                  }
3771                end
3772            "#,
3773        )
3774        .unwrap();
3775        let mut core = Core::new();
3776        frame(&mut core, &mut ext);
3777        let tree = core.access_tree().clone();
3778        let named = |n: &str| {
3779            tree.nodes
3780                .iter()
3781                .find(|node| node.name.as_deref() == Some(n))
3782        };
3783        let go = named("go").expect("the button, named by its label");
3784        assert_eq!(go.role, kui_core::Role::Button);
3785        assert_eq!(go.description.as_deref(), Some("Starts the run"));
3786        assert!(!go.disabled);
3787        let stop = named("Stop the run").expect("named past its text");
3788        assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
3789        assert!(stop.disabled);
3790        assert_eq!(tree.nodes.len(), 4, "window and three buttons");
3791        let ws = core.take_warnings();
3792        assert_eq!(ws.len(), 2, "{ws:?}");
3793        let radius = ws
3794            .iter()
3795            .find(|w| w.message.contains("`radius`"))
3796            .expect("a row the button does not read");
3797        assert!(
3798            radius
3799                .message
3800                .contains("is a prop, but not one button reads")
3801        );
3802        assert!(radius.message.contains("`description`"));
3803        // The camel spelling is a misspelling here, and the fix offered is
3804        // never a row the button would drop.
3805        let hover = ws
3806            .iter()
3807            .find(|w| w.message.contains("hoverBg"))
3808            .expect("a misspelling");
3809        assert!(hover.message.contains("is not a prop of button"));
3810        assert!(!hover.message.contains("did you mean"));
3811    }
3812
3813    /// The one paint row the stock button takes: `accent` is a question
3814    /// put to the OS, not a colour, so a script that declares it gets the
3815    /// stock blue on a host that was never told what the accent is and
3816    /// the OS colour on one that was.
3817    #[test]
3818    fn an_accent_button_takes_the_colour_the_host_pushed() {
3819        let mut ext = LuaExtension::from_source(
3820            "accent",
3821            r#"
3822                function view(env)
3823                  return column { pad = 10,
3824                    button { label = "go", on_click = "go", accent = true },
3825                  }
3826                end
3827            "#,
3828        )
3829        .unwrap();
3830        let mut bg = |core: &mut Core| {
3831            frame(core, &mut ext);
3832            core.output().0.quads[0].color
3833        };
3834        let mut core = Core::new();
3835        assert_eq!(
3836            bg(&mut core),
3837            kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
3838            "no accent pushed, the stock button"
3839        );
3840        assert!(
3841            core.take_warnings().is_empty(),
3842            "and a row the button reads"
3843        );
3844
3845        let accent = kui_core::Color::hex(0x007affff);
3846        core.env.system.accent = Some(accent);
3847        assert_eq!(bg(&mut core), accent);
3848    }
3849
3850    /// are schema rows, so a script declares semantics like any other
3851    /// prop; the access tree shows them (and numbers a tab list itself),
3852    /// and an assistive request on a script's button emits its message.
3853    #[test]
3854    fn semantics_reach_the_access_tree() {
3855        let mut ext = LuaExtension::from_source(
3856            "a11y",
3857            r#"
3858                function view(env)
3859                  return column { pad = 10,
3860                    row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
3861                    row { key = "check", role = "checkbox", checked = true, text("Remember") },
3862                    row { key = "vol", role = "slider", label = "Volume",
3863                          value_now = 3, value_min = 0, value_max = 10 },
3864                    row { key = "art", role = "none", on_click = "art", text("Art") },
3865                    row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
3866                    row { key = "tabs", role = "tabList",
3867                      row { key = "t0", role = "tab", text("General") },
3868                      row { key = "t1", role = "tab", selected = true, text("Network") },
3869                    },
3870                    row { key = "adv", on_click = "adv", expanded = "collapsed",
3871                          label = "Advanced" },
3872                  }
3873                end
3874            "#,
3875        )
3876        .unwrap();
3877        let mut core = Core::new();
3878        frame(&mut core, &mut ext);
3879        let tree = core.access_tree().clone();
3880        let named = |n: &str| {
3881            tree.nodes
3882                .iter()
3883                .find(|node| node.name.as_deref() == Some(n))
3884                .cloned()
3885        };
3886        let save = named("Save").expect("labelled button");
3887        assert_eq!(save.role, kui_core::Role::Button);
3888        assert_eq!(save.origin, OriginId(1));
3889        let check = named("Remember").expect("checkbox named by its text");
3890        assert_eq!(check.role, kui_core::Role::Checkbox);
3891        assert_eq!(check.checked, Some(true));
3892        let vol = named("Volume").expect("slider");
3893        assert_eq!(
3894            (vol.number, vol.min, vol.max),
3895            (Some(3.0), Some(0.0), Some(10.0))
3896        );
3897        assert!(named("Art").is_none(), "role none hides a would-be button");
3898        let tip = named("Tip").expect("button");
3899        assert_eq!(tip.description.as_deref(), Some("more here"));
3900        // Every tab reports the state; the ordinals are the core's, not
3901        // the script's.
3902        let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
3903        assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
3904        assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
3905        let tabs = tree
3906            .nodes
3907            .iter()
3908            .find(|n| n.role == kui_core::Role::TabList)
3909            .expect("tab list");
3910        assert_eq!(tabs.set_size, Some(2));
3911        // An enum row, so a shut disclosure can say it is shut.
3912        let adv = named("Advanced").expect("disclosure");
3913        assert_eq!(adv.expanded, Some(false));
3914
3915        let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
3916            key: save.key,
3917            action: kui_core::AccessAction::Click,
3918            value: None,
3919            anchor: None,
3920            focus: None,
3921        }));
3922        assert_eq!(evs.len(), 1);
3923        assert_eq!(evs[0].origin, OriginId(1));
3924        assert_eq!(evs[0].payload.as_str(), Some("save"));
3925    }
3926
3927    /// A script that draws its own editor: `role = "multilineTextInput"`
3928    /// on the sink, `role = "line"` rows with `caret` / `selection_anchor`
3929    /// byte offsets, and a selection request coming back as a table.
3930    #[test]
3931    fn a_custom_editor_in_lua_reaches_the_access_tree() {
3932        let mut ext = LuaExtension::from_source(
3933            "ed",
3934            r#"
3935                function view(env)
3936                  return column { key = "ed", role = "multilineTextInput", label = "Doc",
3937                    on_key = "keys",
3938                    row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
3939                    row { role = "line", caret = 2, text("ef") },
3940                  }
3941                end
3942            "#,
3943        )
3944        .unwrap();
3945        let mut core = Core::new();
3946        frame(&mut core, &mut ext);
3947        let tree = core.access_tree().clone();
3948        let ed = tree
3949            .nodes
3950            .iter()
3951            .find(|n| n.name.as_deref() == Some("Doc"))
3952            .expect("the sink is the editor")
3953            .clone();
3954        assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
3955        assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
3956        assert_eq!(ed.runs.len(), 3);
3957        assert_eq!(ed.runs[1].text, "cd\n");
3958        assert_eq!(ed.caret, Some(7));
3959        assert_eq!(ed.selection, Some((1, 7)));
3960        let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
3961        assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
3962        assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
3963
3964        let evs = core.handle_input(InputEvent::Access(
3965            kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
3966                .with_selection(
3967                    kui_core::TextPos {
3968                        run: ed.runs[1].key,
3969                        character: 1,
3970                    },
3971                    kui_core::TextPos {
3972                        run: ed.runs[2].key,
3973                        character: 0,
3974                    },
3975                ),
3976        ));
3977        assert_eq!(evs.len(), 1);
3978        assert_eq!(evs[0].origin, OriginId(1));
3979        let p = &evs[0].payload;
3980        assert_eq!(p.get_str("action"), Some("setTextSelection"));
3981        let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
3982        assert_eq!(
3983            (at("anchor", "line"), at("anchor", "offset")),
3984            (Some(0), Some(3))
3985        );
3986        assert_eq!(
3987            (at("focus", "line"), at("focus", "offset")),
3988            (Some(1), Some(0))
3989        );
3990        assert_eq!(p.get_str("tag"), Some("keys"));
3991    }
3992
3993    /// Editors: autofocus, typing produces a "changed" event carrying the
3994    /// node key, and `env.edit_text(key)` reads the buffer back next frame.
3995    #[test]
3996    fn edit_text_round_trips_through_events() {
3997        let mut ext = LuaExtension::from_source(
3998            "edit",
3999            r#"
4000                pending = nil
4001                seen = nil
4002                by_label = nil
4003                window = nil
4004                function view(env)
4005                  if pending then seen = env.edit_text(pending) end
4006                  -- AR26: by the label its `key` declares, like every
4007                  -- query beside it; a label nothing declared is nil.
4008                  by_label = env.edit_text("note")
4009                  nothing = env.edit_text("nope")
4010                  return column {
4011                    edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4012                  }
4013                end
4014                function on_event(ev)
4015                  if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4016                end
4017            "#,
4018        )
4019        .unwrap();
4020        let mut core = Core::new();
4021        frame(&mut core, &mut ext);
4022        let events = core.handle_input(InputEvent::Text("!".into()));
4023        assert_eq!(
4024            events.len(),
4025            1,
4026            "typing into the autofocused editor emits one event"
4027        );
4028        assert_eq!(events[0].kind(), Some("changed"));
4029        for ev in &events {
4030            ext.on_event(ev);
4031        }
4032        frame(&mut core, &mut ext);
4033        // A single-line field opens with the caret after its seed (F20).
4034        let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4035        assert_eq!(seen.as_deref(), Some("hi!"));
4036        let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4037        assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4038        let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4039        assert_eq!(nothing, None);
4040        // The event says which window it came from (AR26), the number
4041        // `env.window.id` reads: the main one here.
4042        let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4043        assert_eq!(window, Some(0));
4044    }
4045
4046    /// `dropdown { }` is the stock select (backlog F73): the click opens
4047    /// the core's menu under the field and reaches the script as nothing;
4048    /// a row chosen is one `menu` event on the field, its `item` the
4049    /// option's label or id, which the script draws back as `current`.
4050    #[test]
4051    fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4052        let mut ext = LuaExtension::from_source(
4053            "dd",
4054            r#"
4055                current = 1
4056                heard = {}
4057                function view(env)
4058                  return column { pad = 10,
4059                    dropdown { label = "language",
4060                               options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4061                               current = current },
4062                  }
4063                end
4064                function on_event(ev)
4065                  heard[#heard + 1] = ev.kind
4066                  if ev.kind == "menu" then
4067                    if ev.item == "Deutsch" then current = 2 end
4068                    if ev.item == "la" then current = 3 end
4069                  end
4070                end
4071            "#,
4072        )
4073        .unwrap();
4074        let mut core = Core::new();
4075        frame(&mut core, &mut ext);
4076        let field = core
4077            .key_of("language")
4078            .expect("the field is keyed by its label");
4079        let node = |core: &mut Core| {
4080            core.access_tree()
4081                .nodes
4082                .iter()
4083                .find(|n| n.key == field)
4084                .cloned()
4085                .unwrap()
4086        };
4087        assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4088        let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4089            field,
4090            kui_core::AccessAction::Click,
4091        )));
4092        assert!(
4093            events.is_empty(),
4094            "the field's click is the core's: {events:?}"
4095        );
4096        let menu = core.menu().expect("the menu opened");
4097        assert_eq!(menu.target, field);
4098        assert_eq!(menu.items.len(), 3);
4099        assert!(menu.items[0].checked);
4100        frame(&mut core, &mut ext);
4101        // The row, as a host's menu would answer it.
4102        let events = core.activate_menu_item(2).expect("the row is enabled");
4103        assert_eq!(events.len(), 1);
4104        for ev in &events {
4105            ext.on_event(ev);
4106        }
4107        frame(&mut core, &mut ext);
4108        assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4109        let heard: Vec<String> = ext
4110            .lua
4111            .globals()
4112            .get::<Table>("heard")
4113            .unwrap()
4114            .sequence_values()
4115            .collect::<mlua::Result<_>>()
4116            .unwrap();
4117        assert_eq!(heard, vec!["menu".to_string()]);
4118        assert!(core.menu().is_none());
4119        // What the table refuses: no options, a current from 0.
4120        let mut ext = LuaExtension::from_source(
4121            "bad",
4122            r#"function view(env) return dropdown { label = "x" } end"#,
4123        )
4124        .unwrap();
4125        let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4126        ui.set_origin(OriginId(1));
4127        let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4128        assert!(err.contains("needs options"), "{err}");
4129        ui.finish();
4130        let mut ext = LuaExtension::from_source(
4131            "bad",
4132            r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
4133        )
4134        .unwrap();
4135        let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4136        ui.set_origin(OriginId(1));
4137        let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4138        assert!(err.contains("index from 1"), "{err}");
4139        ui.finish();
4140        // And, by name (backlog RG10): no label, and no options at all —
4141        // the core's one reader refusing the empty list.
4142        let refused = |core: &mut Core, src: &str| {
4143            let mut ext = LuaExtension::from_source("bad", src).unwrap();
4144            let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4145            ui.set_origin(OriginId(1));
4146            let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4147            ui.finish();
4148            err
4149        };
4150        let err = refused(
4151            &mut core,
4152            r#"function view(env) return dropdown { options = { "a" } } end"#,
4153        );
4154        assert!(err.contains("dropdown needs a label"), "{err}");
4155        let err = refused(
4156            &mut core,
4157            r#"function view(env) return dropdown { label = "x", options = {} } end"#,
4158        );
4159        assert!(err.contains("at least one option"), "{err}");
4160    }
4161
4162    /// The select's checks the core makes for every binding, seen from
4163    /// Lua (backlog RG9, RG10): a row's key no row reads warns as an
4164    /// unknown prop does, `current` past the end or on a separator warns
4165    /// and is none, and a disabled option reported chosen is refused with
4166    /// the menu still open.
4167    #[test]
4168    fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
4169        let mut ext = LuaExtension::from_source(
4170            "dd",
4171            r#"
4172                current = 4
4173                function view(env)
4174                  return column { pad = 10,
4175                    dropdown { label = "language",
4176                               options = { "English", { role = "separator" },
4177                                           { label = "Latin", id = "la", disabled = true } },
4178                               current = current },
4179                  }
4180                end
4181            "#,
4182        )
4183        .unwrap();
4184        let mut core = Core::new();
4185        frame(&mut core, &mut ext);
4186        let field = core.key_of("language").unwrap();
4187        let node = |core: &mut Core| {
4188            core.access_tree()
4189                .nodes
4190                .iter()
4191                .find(|n| n.key == field)
4192                .cloned()
4193                .unwrap()
4194        };
4195        assert_eq!(node(&mut core).description.as_deref(), Some(""));
4196        let warned = core.take_warnings();
4197        let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
4198        assert_eq!(
4199            codes,
4200            [
4201                kui_core::diag::UNKNOWN_PROP,
4202                kui_core::diag::SELECT_CURRENT_IGNORED
4203            ],
4204            "{warned:?}"
4205        );
4206        assert!(
4207            warned[0]
4208                .message
4209                .contains("`disabled` is not a key of a menu item")
4210                && warned[0].message.contains("`enabled: false`"),
4211            "{}",
4212            warned[0].message
4213        );
4214        assert_eq!(warned[1].key, field);
4215        assert!(
4216            warned[1].message.contains("names option 3 counted from 0")
4217                && warned[1].message.contains("the field has 3 options"),
4218            "{}",
4219            warned[1].message
4220        );
4221        // The separator in force: the core's index, so `current = 2`.
4222        ext.lua.globals().set("current", 2).unwrap();
4223        let mut core = Core::new();
4224        frame(&mut core, &mut ext);
4225        let warned = core.take_warnings();
4226        assert!(
4227            warned
4228                .iter()
4229                .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
4230                    && w.message
4231                        .contains("option 1 counted from 0, which is a separator")),
4232            "{warned:?}"
4233        );
4234        // `disabled` was dropped, so Latin is enabled and can be chosen:
4235        // spell it as the row reads it and the door refuses it.
4236        let mut ext = LuaExtension::from_source(
4237            "dd",
4238            r#"
4239                function view(env)
4240                  return column { pad = 10,
4241                    dropdown { label = "language",
4242                               options = { "English", { label = "Latin", id = "la", enabled = false } },
4243                               current = 1 },
4244                  }
4245                end
4246            "#,
4247        )
4248        .unwrap();
4249        let mut core = Core::new();
4250        frame(&mut core, &mut ext);
4251        let field = core.key_of("language").unwrap();
4252        core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4253            field,
4254            kui_core::AccessAction::Click,
4255        )));
4256        assert!(core.menu().is_some());
4257        assert_eq!(core.activate_menu_item(1), None, "refused");
4258        assert!(core.menu().is_some(), "the menu stays open");
4259        let events = core
4260            .activate_menu_item(0)
4261            .expect("the enabled row is taken");
4262        assert_eq!(events.len(), 1);
4263        assert!(core.menu().is_none());
4264        assert!(core.take_warnings().is_empty());
4265    }
4266
4267    /// `grid { }` is a table (ADR 0033): its rows' cells line up, each
4268    /// column as wide as its widest cell, a bare text a cell too.
4269    #[test]
4270    fn a_grid_lines_its_rows_cells_up() {
4271        let mut ext = LuaExtension::from_source(
4272            "grid",
4273            r#"
4274                function view(env)
4275                  return grid { key = "t", width = 300,
4276                    row { key = "r1", width = "grow", gap = 8,
4277                      text("ab", { size = 12 }),
4278                      column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
4279                      column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
4280                    },
4281                    row { key = "r2", width = "grow", gap = 8,
4282                      text("abcdef", { size = 12 }),
4283                      column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
4284                      column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
4285                    },
4286                  }
4287                end
4288            "#,
4289        )
4290        .unwrap();
4291        let mut core = Core::new();
4292        core.set_inspect(true);
4293        frame(&mut core, &mut ext);
4294        let rect = |core: &mut Core, label: &str| {
4295            let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
4296            core.nodes()
4297                .iter()
4298                .find(|n| n.key == key)
4299                .map(|n| n.rect)
4300                .unwrap_or_else(|| panic!("{label}"))
4301        };
4302        let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
4303        let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
4304        assert_eq!(
4305            b1.x, b2.x,
4306            "the fixed column starts after the longest label"
4307        );
4308        assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
4309        assert_eq!(b2.w, 50.0);
4310        assert_eq!(c1.x, c2.x);
4311        assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
4312        assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
4313        let t = core.key_of("t").unwrap();
4314        assert!(
4315            core.nodes().iter().any(|n| n.key == t && n.table),
4316            "the grid is a table"
4317        );
4318        assert!(core.take_warnings().is_empty());
4319    }
4320
4321    /// `env.set_edit_text` by the label the view declares: the spelling a
4322    /// script that is *opening* the editor can use, since the key comes
4323    /// from an event the editor has not fired (backlog F32). The frame
4324    /// that declares the field takes the held text over its `initial`.
4325    #[test]
4326    fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
4327        let mut ext = LuaExtension::from_source(
4328            "edit",
4329            r#"
4330                frames = 0
4331                function view(env)
4332                  frames = frames + 1
4333                  if frames == 1 then return column {} end
4334                  if frames == 2 then
4335                    env.set_edit_text("note", "from the model")
4336                  end
4337                  return column {
4338                    edit { key = "note", initial = "ignored", width = 200 },
4339                  }
4340                end
4341            "#,
4342        )
4343        .unwrap();
4344        let mut core = Core::new();
4345        // A frame with no editor in it, then the one that opens the field:
4346        // `env` lives inside `view`, so the call is made from the frame
4347        // that declares the editor and its tree is what claims the text.
4348        frame(&mut core, &mut ext);
4349        frame(&mut core, &mut ext);
4350        let key = core.key_of("note").expect("the view declared the editor");
4351        assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
4352        let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
4353        assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
4354        // And it is the seed, not a per-frame reset: the next frame's
4355        // typing is kept.
4356        core.set_focus(Some(key));
4357        core.handle_input(InputEvent::Text("!".into()));
4358        frame(&mut core, &mut ext);
4359        assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
4360    }
4361
4362    /// A script that owns its keyboard and asks for releases (`key_up`)
4363    /// sees both halves of a key on one `{kind="key"}` payload: a held key
4364    /// is `phase="down"` then `"up"`, and focus leaving while it is held
4365    /// delivers the `up` anyway.
4366    #[test]
4367    fn a_lua_key_sink_hears_press_and_release() {
4368        use kui_core::{KeyCode, KeyMods, KeyPress};
4369        let mut ext = LuaExtension::from_source(
4370            "game",
4371            r#"
4372                log = {}
4373                function view(env)
4374                  return column { key = "world", on_key = "keys", key_up = true,
4375                    key_focus = true, width = 400, height = 300 }
4376                end
4377                function on_event(ev)
4378                  if ev.kind == "key" then
4379                    log[#log + 1] = ev.phase .. ":" .. ev.code ..
4380                      "@" .. ev.physical ..
4381                      ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
4382                  end
4383                end
4384            "#,
4385        )
4386        .unwrap();
4387        let mut core = Core::new();
4388        frame(&mut core, &mut ext);
4389        let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4390            for e in core.handle_input(ev) {
4391                ext.on_event(&e);
4392            }
4393        };
4394        let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
4395        feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4396        feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
4397        // The same key on a Russian layout, as a driver reports it: the
4398        // layout says "ц", the position says W. A script matching on
4399        // `ev.code` keeps working, and `ev.physical` is there for one that
4400        // would rather bind the position.
4401        let ru = || {
4402            KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
4403                .with_text("ц")
4404        };
4405        feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
4406        feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
4407        // Pressed again, then focus dropped while it is still down.
4408        feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4409        core.set_focus(None);
4410        for e in core.take_pending_events() {
4411            ext.on_event(&e);
4412        }
4413        let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4414        assert_eq!(
4415            log,
4416            [
4417                "down:w@w:w:keys",
4418                // A release inserts nothing, so `text` is nil in Lua.
4419                "up:w@w:nil:keys",
4420                // The layout key never reaches `code`; the text it inserts
4421                // is still the layout's own.
4422                "down:w@w:ц:keys",
4423                "up:w@w:nil:keys",
4424                "down:w@w:w:keys",
4425                "up:w@w:nil:keys",
4426            ]
4427        );
4428    }
4429
4430    /// A script's editor blinks (backlog C35): `env.caret_visible` is the
4431    /// phase, read in `view`; the `caret` row on its line is what the
4432    /// host's clock is armed on, kept through the off phase.
4433    #[test]
4434    fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
4435        let mut ext = LuaExtension::from_source(
4436            "ed",
4437            r#"
4438                function view(env)
4439                  local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
4440                  return column { key = "editor", on_key = "ed", key_focus = true,
4441                    role = "multilineTextInput", label = "buf",
4442                    row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
4443                  }
4444                end
4445            "#,
4446        )
4447        .unwrap();
4448        let mut core = Core::new();
4449        frame(&mut core, &mut ext);
4450        assert!(
4451            core.key_of("caret").is_some(),
4452            "the on phase draws the caret"
4453        );
4454        assert!(core.has_caret(), "the caret row is a caret to blink");
4455        core.set_caret_visible(false);
4456        frame(&mut core, &mut ext);
4457        assert!(core.key_of("caret").is_none(), "the off phase draws none");
4458        assert!(
4459            core.has_caret(),
4460            "and the row stays, so the clock stays armed"
4461        );
4462        core.set_caret_visible(true);
4463        frame(&mut core, &mut ext);
4464        assert!(core.key_of("caret").is_some());
4465    }
4466
4467    /// A script's block caret is solid (backlog F68): `caret_solid = true`
4468    /// beside `caret` on the line keeps the IME anchor and the access
4469    /// tree's caret and arms no clock, so a script idling in normal mode
4470    /// draws no frame for it; the line without it blinks again.
4471    #[test]
4472    fn a_lua_editors_solid_caret_arms_no_clock() {
4473        let mut ext = LuaExtension::from_source(
4474            "ed",
4475            r#"
4476                function view(env)
4477                  return column { key = "editor", on_key = "ed", key_focus = true,
4478                    role = "multilineTextInput", label = "buf",
4479                    row { role = "line", caret = 3, caret_solid = true,
4480                      text("let value", { family = "mono", size = 14 }) },
4481                  }
4482                end
4483            "#,
4484        )
4485        .unwrap();
4486        let mut core = Core::new();
4487        frame(&mut core, &mut ext);
4488        assert!(!core.has_caret(), "a solid caret is not a caret to blink");
4489        assert!(core.ime_rect().is_some(), "and still the IME's anchor");
4490        let editor = core.key_of("editor").unwrap();
4491        assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
4492        assert!(
4493            core.warnings_raised().is_empty(),
4494            "{:?}",
4495            core.warnings_raised()
4496        );
4497    }
4498
4499    /// A script that owns its text has a clipboard (backlog C33) and a
4500    /// mouse (C34): `y` in its keymap queues `env.set_clipboard` from the
4501    /// next view and `p` asks `env.request_paste`, whose answer arrives as
4502    /// a `text` event on the sink; a press inside the sink says which
4503    /// `role = "line"` row it landed on, where, and how many clicks.
4504    #[test]
4505    fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
4506        use kui_core::testing::{press, release};
4507        use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
4508        let mut ext = LuaExtension::from_source(
4509            "ed",
4510            r#"
4511                yank = nil
4512                paste = false
4513                log = {}
4514                function view(env)
4515                  if yank then env.set_clipboard(yank, nil); yank = nil end
4516                  if secret then env.set_clipboard_secret(secret); secret = nil end
4517                  -- Asked on every view until the answer lands: the core
4518                  -- takes one ask at a time (AR34), so this is one paste.
4519                  if paste then env.request_paste() end
4520                  return column { key = "editor", on_key = "ed", on_drag = "sel",
4521                    key_focus = true, role = "multilineTextInput", label = "buf",
4522                    width = 400, height = 300,
4523                    row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
4524                    row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
4525                  }
4526                end
4527                function on_event(ev)
4528                  if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
4529                  if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
4530                  if ev.kind == "key" and ev.code == "p" then paste = true end
4531                  if ev.kind == "text" then
4532                    paste = false
4533                    local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
4534                    log[#log + 1] = "text:" .. ev.text .. marks
4535                  end
4536                  if ev.kind == "drag" then
4537                    log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
4538                  end
4539                end
4540            "#,
4541        )
4542        .unwrap();
4543        let mut core = Core::new();
4544        frame(&mut core, &mut ext);
4545        let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4546            for e in core.handle_input(ev) {
4547                ext.on_event(&e);
4548            }
4549        };
4550        let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
4551        feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
4552        frame(&mut core, &mut ext);
4553        assert_eq!(
4554            core.take_menu_actions(),
4555            vec![MenuAction::SetClipboard {
4556                text: "hello world".into(),
4557                html: None
4558            }]
4559        );
4560        // A secret, for the host to write marked (backlog F84).
4561        feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
4562        frame(&mut core, &mut ext);
4563        assert_eq!(
4564            core.take_menu_actions(),
4565            vec![MenuAction::SetClipboardSecret {
4566                text: "hunter2".into()
4567            }]
4568        );
4569        feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
4570        frame(&mut core, &mut ext);
4571        frame(&mut core, &mut ext);
4572        assert_eq!(
4573            core.take_menu_actions(),
4574            vec![MenuAction::Paste],
4575            "two views asked, one paste queued"
4576        );
4577        assert!(core.awaiting_paste());
4578        // The host reads the clipboard and commits it.
4579        feed(
4580            &mut core,
4581            &mut ext,
4582            InputEvent::Commit("from the clipboard".into()),
4583        );
4584        assert!(!core.awaiting_paste());
4585        frame(&mut core, &mut ext);
4586        assert!(
4587            core.take_menu_actions().is_empty(),
4588            "answered: the script stopped asking"
4589        );
4590        // A paste the pasteboard marked: the script reads the markers.
4591        feed(
4592            &mut core,
4593            &mut ext,
4594            InputEvent::Paste {
4595                text: "s3cret".into(),
4596                marks: kui_core::ClipboardMarks::SECRET,
4597            },
4598        );
4599        // A double click on the second line, past its end.
4600        for e in press(&mut core, Vec2::new(390.0, 30.0)) {
4601            ext.on_event(&e);
4602        }
4603        for e in release(&mut core) {
4604            ext.on_event(&e);
4605        }
4606        for e in core.handle_input(InputEvent::mouse_down(2)) {
4607            ext.on_event(&e);
4608        }
4609        for e in release(&mut core) {
4610            ext.on_event(&e);
4611        }
4612        let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4613        assert_eq!(
4614            log,
4615            [
4616                "text:from the clipboard",
4617                "text:s3cret:concealed:transient",
4618                "start:1:6:1",
4619                "end:1:6:1",
4620                "start:1:6:2",
4621                "end:1:6:2",
4622            ]
4623        );
4624    }
4625
4626    /// `audio { }` nodes are retained playbacks: declared → play, declared
4627    /// again → nothing, gone → stop. The host hands the sound id to the
4628    /// script as an integer, like images.
4629    #[test]
4630    fn audio_nodes_drive_playback_commands() {
4631        use kui_core::AudioCommand;
4632        let mut ext = LuaExtension::from_source(
4633            "audio",
4634            r#"
4635                playing = true
4636                function view(env)
4637                  local items = {}
4638                  if playing then
4639                    items[1] = audio { src = SOUND, loop = true, volume = 0.5,
4640                                       key = "music", tag = { kind = "music" } }
4641                  end
4642                  return column { table.unpack(items) }
4643                end
4644            "#,
4645        )
4646        .unwrap();
4647        let mut core = Core::new();
4648        let sound = core.add_sound(vec![0; 8]);
4649        ext.lua
4650            .globals()
4651            .set("SOUND", sound.to_ffi() as i64)
4652            .unwrap();
4653        frame(&mut core, &mut ext);
4654        let cmds = core.take_audio_commands();
4655        assert!(
4656            matches!(
4657                cmds.as_slice(),
4658                [AudioCommand::Play { sound: s, looped: true, volume, .. }]
4659                    if *s == sound && *volume == 0.5
4660            ),
4661            "{cmds:?}"
4662        );
4663        frame(&mut core, &mut ext);
4664        assert!(
4665            core.take_audio_commands().is_empty(),
4666            "re-declaring is silent"
4667        );
4668        ext.lua.globals().set("playing", false).unwrap();
4669        frame(&mut core, &mut ext);
4670        assert!(matches!(
4671            core.take_audio_commands().as_slice(),
4672            [AudioCommand::Stop { .. }]
4673        ));
4674    }
4675
4676    /// `env.measure_text` answers what layout gives the same text, in every
4677    /// input shape, and `on_layout` rects arrive in `on_event` with the
4678    /// node key like any other event.
4679    #[test]
4680    fn measure_and_layout_events_reach_scripts() {
4681        let mut ext = LuaExtension::from_source(
4682            "measure",
4683            r#"
4684                seen = nil
4685                function view(env)
4686                  local plain = env.measure_text("hello world", { size = 14 })
4687                  local node = env.measure_text(text("hello world", { size = 14 }))
4688                  local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
4689                  local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
4690                  assert(plain.width > 0 and plain.lines == 1)
4691                  assert(node.width == plain.width and node.height == plain.height)
4692                  assert(rich.width > 0)
4693                  assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
4694                  return column {
4695                    row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
4696                  }
4697                end
4698                function on_event(ev)
4699                  if ev.kind == "layout" then seen = ev end
4700                end
4701            "#,
4702        )
4703        .unwrap();
4704        let mut core = Core::new();
4705        frame(&mut core, &mut ext);
4706        let evs = core.take_pending_events();
4707        assert_eq!(evs.len(), 1, "one layout event on first sight");
4708        for ev in &evs {
4709            ext.on_event(ev);
4710        }
4711        let seen: Table = ext.lua.globals().get("seen").unwrap();
4712        assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
4713        assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
4714        assert!(seen.get::<f64>("w").unwrap() > 0.0);
4715        assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
4716        // Unchanged next frame: silence.
4717        frame(&mut core, &mut ext);
4718        assert!(core.take_pending_events().is_empty());
4719    }
4720
4721    /// Window requests from a script queue like a reveal — against the
4722    /// frame being built, drained by the driver after it — in call order,
4723    /// and once; a headless core keeps them and nothing else changes.
4724    #[test]
4725    fn scripts_queue_window_size_and_focus_requests() {
4726        let mut ext = LuaExtension::from_source(
4727            "win",
4728            r#"
4729                function view(env)
4730                  env.set_window_size(env.window.id, 640, 480)
4731                  env.focus_window(env.window.id)
4732                  return column { width = "grow", height = "grow" }
4733                end
4734            "#,
4735        )
4736        .unwrap();
4737        let mut core = Core::new();
4738        let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
4739        ui.set_origin(OriginId(1));
4740        ext.view(&Slot::root(), &mut ui).unwrap();
4741        ui.finish();
4742        assert_eq!(
4743            core.take_window_commands(),
4744            vec![
4745                kui_core::WindowCommand::SetSize {
4746                    window: WindowId::MAIN,
4747                    size: Size::new(640.0, 480.0),
4748                },
4749                kui_core::WindowCommand::Focus(WindowId::MAIN),
4750            ]
4751        );
4752        assert!(core.take_window_commands().is_empty());
4753        assert_eq!(core.viewport(), Size::new(400.0, 200.0));
4754    }
4755
4756    /// Scrolling from a script: `env.reveal` scrolls a row into view against
4757    /// the frame the script is building, and `env.set_scroll` /
4758    /// `env.scroll_offset` write and read the retained offset. Keys are the
4759    /// integers events carry, so a script reveals the row it got an
4760    /// `on_click` from.
4761    #[test]
4762    fn scripts_reveal_and_move_scroll_offsets() {
4763        let mut ext = LuaExtension::from_source(
4764            "scroll",
4765            r#"
4766                rows, want, jump, seen = 20, nil, nil, nil
4767                function view(env)
4768                  if want then env.reveal(want) end
4769                  if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
4770                  want, jump = nil, nil
4771                  local list = { key = "list", width = "grow", height = "grow",
4772                                 scroll_y = true }
4773                  for i = 0, rows - 1 do
4774                    list[#list + 1] = row { key = "row" .. i, width = "grow",
4775                                            height = 30, bg = 0x282840ff }
4776                  end
4777                  seen = env.scroll_offset(list_key)
4778                  return column(list)
4779                end
4780            "#,
4781        )
4782        .unwrap();
4783        // 20 rows of 30 in a 200-tall window: 400 of overflow.
4784        let list = Key::ROOT.str("list");
4785        let row = |i: usize| list.str(&format!("row{i}"));
4786        ext.lua.globals().set("list_key", list.0 as i64).unwrap();
4787        let mut core = Core::new();
4788        let frame = |core: &mut Core, ext: &mut LuaExtension| {
4789            let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
4790            ui.set_origin(OriginId(1));
4791            ext.view(&Slot::root(), &mut ui).unwrap();
4792            ui.finish();
4793        };
4794
4795        frame(&mut core, &mut ext);
4796        assert_eq!(core.scroll_offset(list), Vec2::ZERO);
4797
4798        // A reveal made while the frame is being built resolves against that
4799        // same frame — the script does not have to wait a frame to see it.
4800        ext.lua.globals().set("want", row(15).0 as i64).unwrap();
4801        frame(&mut core, &mut ext);
4802        let after = core.scroll_offset(list);
4803        assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
4804        assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
4805        // Spent: the next frame does not drift.
4806        frame(&mut core, &mut ext);
4807        assert_eq!(core.scroll_offset(list), after);
4808
4809        // The script reads the offset back (as of the last layout).
4810        let seen: Table = ext.lua.globals().get("seen").unwrap();
4811        assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
4812        assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
4813
4814        // set_scroll jumps, and the layout clamps: "to the end", then home.
4815        let jump = |ext: &LuaExtension, y: f64| {
4816            let t = ext.lua.create_table().unwrap();
4817            t.set(1, list.0 as i64).unwrap();
4818            t.set(2, 0.0).unwrap();
4819            t.set(3, y).unwrap();
4820            ext.lua.globals().set("jump", t).unwrap();
4821        };
4822        jump(&ext, 1e9);
4823        frame(&mut core, &mut ext);
4824        assert_eq!(core.scroll_offset(list).y, 400.0);
4825        jump(&ext, -1e9);
4826        frame(&mut core, &mut ext);
4827        assert_eq!(core.scroll_offset(list), Vec2::ZERO);
4828
4829        // A key the frame does not declare is a no-op, and is not kept.
4830        jump(&ext, 120.0);
4831        frame(&mut core, &mut ext);
4832        ext.lua
4833            .globals()
4834            .set("want", Key::ROOT.str("ghost").0 as i64)
4835            .unwrap();
4836        frame(&mut core, &mut ext);
4837        assert_eq!(core.scroll_offset(list).y, 120.0);
4838        frame(&mut core, &mut ext);
4839        assert_eq!(core.scroll_offset(list).y, 120.0);
4840    }
4841
4842    /// `env.is_pressed(key)` completes the interaction trio next to
4843    /// `is_hovered` / `is_focused`: held down means the press landed on
4844    /// this node and the pointer is still on it.
4845    #[test]
4846    fn scripts_read_the_pressed_state() {
4847        let mut ext = LuaExtension::from_source(
4848            "press",
4849            r#"
4850                function view(env)
4851                  pressed = env.is_pressed(btn_key)
4852                  hovered = env.is_hovered(btn_key)
4853                  return column { key = "root", pad = 10,
4854                    row { key = "btn", width = 100, height = 40,
4855                          bg = 0x333333ff, on_click = "hit" },
4856                  }
4857                end
4858            "#,
4859        )
4860        .unwrap();
4861        let btn = Key::ROOT.str("root").str("btn");
4862        ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
4863        let mut core = Core::new();
4864        let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
4865        let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
4866
4867        frame(&mut core, &mut ext);
4868        assert!(!pressed(&ext), "idle");
4869
4870        // Hover alone is not a press.
4871        core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4872        frame(&mut core, &mut ext);
4873        assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
4874
4875        core.handle_input(InputEvent::MouseDown {
4876            button: kui_core::MouseButton::Primary,
4877            clicks: 1,
4878        });
4879        frame(&mut core, &mut ext);
4880        assert!(pressed(&ext), "held down");
4881
4882        // The press is stuck to the node it started on, so wandering off a
4883        // node with no drag releases the pressed look while the button is
4884        // still down; the release clears it either way.
4885        core.handle_input(InputEvent::MouseUp {
4886            button: kui_core::MouseButton::Primary,
4887        });
4888        frame(&mut core, &mut ext);
4889        assert!(!pressed(&ext), "released");
4890    }
4891
4892    /// `env.is_drop_target(key)` and `env.drop_target()` beside the trio
4893    /// (ADR 0031): a script that shows an insertion mark while files
4894    /// hover reads them; the colour alone is `drop_bg`, resolved in the
4895    /// core.
4896    #[test]
4897    fn scripts_read_the_drop_target() {
4898        let mut ext = LuaExtension::from_source(
4899            "drop",
4900            r#"
4901                function view(env)
4902                  over = env.is_drop_target(zone_key)
4903                  target = env.drop_target()
4904                  return column { key = "root", pad = 10,
4905                    row { key = "zone", width = 100, height = 40,
4906                          bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
4907                  }
4908                end
4909            "#,
4910        )
4911        .unwrap();
4912        let zone = Key::ROOT.str("root").str("zone");
4913        ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
4914        let mut core = Core::new();
4915        let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
4916        let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
4917
4918        frame(&mut core, &mut ext);
4919        assert!(!over(&ext) && target(&ext).is_none(), "idle");
4920
4921        let paths = vec!["/drop/1.txt".to_string()];
4922        let evs = core.handle_input(InputEvent::DragFiles {
4923            paths: paths.clone(),
4924            at: Vec2::new(50.0, 30.0),
4925        });
4926        assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
4927        frame(&mut core, &mut ext);
4928        assert!(over(&ext), "the files are over the zone");
4929        assert_eq!(target(&ext), Some(zone.0 as i64));
4930        let (dl, _) = core.output();
4931        let lit = dl
4932            .quads
4933            .iter()
4934            .find(|q| q.rect.w == 100.0)
4935            .map(|q| q.color)
4936            .expect("zone quad");
4937        assert!(
4938            (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
4939            "drop_bg painted"
4940        );
4941
4942        core.handle_input(InputEvent::DropFiles {
4943            paths,
4944            at: Vec2::new(50.0, 30.0),
4945        });
4946        frame(&mut core, &mut ext);
4947        assert!(
4948            !over(&ext) && target(&ext).is_none(),
4949            "a drop ends the hover"
4950        );
4951    }
4952
4953    /// The focus verbs: `env.set_focus(key)` moves focus now,
4954    /// `env.focus_next` / `env.focus_prev` walk the Tab ring, `env.blur`
4955    /// drops it — and `env.focus` reads back the node's key, which is a
4956    /// different fact from `env.focused`, the window's.
4957    /// The table `view(env)` gets is the documented env reading and
4958    /// nothing else: its value keys are `schema::ENV_FIELDS`'s Lua
4959    /// spellings, key for key, under an env with every optional fact
4960    /// present. The queries and verbs beside them are pinned to the verb
4961    /// table's Lua column (`schema::DOORS`), so adding one is a row there
4962    /// with its C and Node cells beside it.
4963    #[test]
4964    fn the_env_table_is_the_documented_env_shape() {
4965        let mut ext = LuaExtension::from_source(
4966            "env",
4967            r#"
4968                function view(env)
4969                  values, calls = {}, {}
4970                  for k, v in pairs(env) do
4971                    if type(v) == "function" then calls[#calls + 1] = k
4972                    elseif type(v) == "table" then
4973                      for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
4974                    else values[#values + 1] = k end
4975                  end
4976                  return column { key = "root",
4977                    row { key = "a", focusable = true, width = 50, height = 20 },
4978                    column { key = "dock", focus_region = true,
4979                      row { key = "d", focusable = true, width = 50, height = 20 },
4980                    },
4981                  }
4982                end
4983            "#,
4984        )
4985        .unwrap();
4986        let mut core = Core::new();
4987        // Every key that only appears when set: a rate, an accent, a
4988        // locale, a controls rect, and a focused node (which the ring has
4989        // to see a frame first) — one inside a region, so the region in
4990        // effect is a reading too.
4991        core.env.refresh_hz = Some(60.0);
4992        core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
4993        core.env.system.locale = kui_core::Locale::new("en-US");
4994        core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
4995        frame(&mut core, &mut ext);
4996        core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
4997        frame(&mut core, &mut ext);
4998
4999        let sorted = |name: &str| -> Vec<String> {
5000            let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
5001            v.sort();
5002            v
5003        };
5004        let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
5005            .iter()
5006            .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
5007            .collect();
5008        // The palette rides in the same reading and is pinned the same
5009        // way, to `schema::THEME_ROLES` rather than to `ENV_FIELDS` — it
5010        // is derived from `system` and not reported by the host, so it is
5011        // not an `Env` field (ADR 0019). The two flat keys beside the
5012        // roles are the base it came from and the disabled multiplier.
5013        documented.extend(
5014            kui_core::schema::THEME_ROLES
5015                .iter()
5016                .map(|r| format!("theme.{}", r.name)),
5017        );
5018        documented.push("theme.appearance".into());
5019        documented.push("theme.disabled_opacity".into());
5020        // And the metrics beside it (backlog T2), pinned to
5021        // `schema::METRIC_ROLES` the same way.
5022        documented.extend(
5023            kui_core::schema::METRIC_ROLES
5024                .iter()
5025                .map(|r| format!("metrics.{}", r.name)),
5026        );
5027        // And the tokens (ADR 0027): two tables, the app's own names
5028        // under them, so the keys pinned here are the two halves.
5029        documented.push("tokens.colors".into());
5030        documented.push("tokens.lengths".into());
5031        documented.sort();
5032        assert_eq!(
5033            sorted("values"),
5034            documented,
5035            "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
5036        );
5037        // The queries and verbs are the verb table's Lua column, exactly
5038        // (`schema::DOORS`, backlog B1a): a function added to `env` is a
5039        // row there with its three other cells, and a row's Lua spelling
5040        // is a function here. The table carries the reasons for the
5041        // rows Lua has no door for — a guest's env is a reading, not a
5042        // handle on the host — so this test need not restate them.
5043        let mut doors: Vec<String> = kui_core::schema::DOORS
5044            .iter()
5045            .filter_map(|d| match d.lua {
5046                kui_core::schema::Cell::Is(name) => Some(name.to_string()),
5047                _ => None,
5048            })
5049            .collect();
5050        doors.sort();
5051        assert_eq!(
5052            sorted("calls"),
5053            doors,
5054            "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
5055        );
5056    }
5057
5058    /// Tokens (ADR 0027): the `tokens` global is declared under the
5059    /// script's origin, a `$name` resolves in a colour, a length, a sizing,
5060    /// a pad edge, a border and a span, a themed colour follows the
5061    /// appearance, and `env.tokens` reads the frame's values back.
5062    #[test]
5063    fn a_script_declares_tokens_and_references_them_by_name() {
5064        let mut ext = LuaExtension::from_source(
5065            "tokens",
5066            r##"
5067                tokens = {
5068                  colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
5069                  lengths = { side_w = 132, gap = 6 },
5070                }
5071                function view(env)
5072                  seen = env.tokens
5073                  return column { pad = "$gap", gap = "$gap",
5074                    row { key = "a", width = "$side_w", height = 20, bg = "$peach",
5075                          border = { w = "$gap", color = "$ink" }, radius = "$radius" },
5076                    row { key = "b", width = 20, height = "$side_w", bg = "$surface",
5077                          pad = { l = "$gap", r = 2 } },
5078                    text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
5079                  }
5080                end
5081            "##,
5082        )
5083        .unwrap();
5084        let mut core = Core::new();
5085        frame(&mut core, &mut ext);
5086        assert!(core.take_warnings().is_empty());
5087        let peach = Color::hex(0xffcc99ff);
5088        let paper = Color::hex(0xeeeeeeff);
5089        let radius = core.metrics().radius;
5090        let surface = core.theme().surface;
5091        let dl = core.output().0;
5092        let a = dl
5093            .quads
5094            .iter()
5095            .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5096            .expect("the peach box");
5097        assert_eq!(a.rect.w, 132.0, "width from a length token");
5098        assert_eq!(a.border_w, 6.0);
5099        assert_eq!(
5100            a.border_color, paper,
5101            "the dark half on an unknown appearance"
5102        );
5103        assert_eq!(a.radius[0], radius, "$radius is the metric");
5104        let b = dl
5105            .quads
5106            .iter()
5107            .find(|q| q.color == surface && q.rect.h == 132.0)
5108            .expect("the $surface box, 132 tall");
5109        assert_eq!(b.rect.w, 20.0);
5110        let seen: Table = ext.lua.globals().get("seen").unwrap();
5111        let colors: Table = seen.get("colors").unwrap();
5112        let lengths: Table = seen.get("lengths").unwrap();
5113        assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
5114        assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
5115        assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
5116        // The light half, without the script changing.
5117        core.set_system(kui_core::SystemEnv {
5118            appearance: kui_core::Appearance::Light,
5119            ..Default::default()
5120        });
5121        frame(&mut core, &mut ext);
5122        let a = core
5123            .output()
5124            .0
5125            .quads
5126            .iter()
5127            .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5128            .unwrap();
5129        assert_eq!(a.border_color, Color::hex(0x111111ff));
5130    }
5131
5132    /// Derived tokens (ADR 0028): a recipe over an earlier token, its ops
5133    /// tuples in the array part. Lua sorts its names, so `a_deep` (from
5134    /// `z_lit`) is declared after its source by dependency and not by
5135    /// position; `bad`'s missing source is the core's `unknown-token`; a
5136    /// bare tuple is one op; `env.tokens` reads the derived value back.
5137    #[test]
5138    fn a_script_derives_a_token_from_another() {
5139        let mut ext = LuaExtension::from_source(
5140            "derived",
5141            r##"
5142                tokens = {
5143                  colors = {
5144                    peach = "#ffcc99",
5145                    z_lit = { from = "peach", ops = { "lift", 0.5 } },
5146                    a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
5147                    up = { from = "surface", ops = { { "raise", 0.25 } } },
5148                    bad = { from = "nothing" },
5149                  },
5150                }
5151                function view(env)
5152                  seen = env.tokens.colors
5153                  return column {
5154                    row { key = "a", width = 20, height = 20, bg = "$a_deep" },
5155                    row { key = "b", width = 20, height = 20, bg = "$bad" },
5156                  }
5157                end
5158            "##,
5159        )
5160        .unwrap();
5161        let mut core = Core::new();
5162        frame(&mut core, &mut ext);
5163        let warnings = core.take_warnings();
5164        let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
5165        assert_eq!(codes, ["unknown-token"], "{warnings:?}");
5166        assert!(
5167            warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
5168        );
5169        let seen: Table = ext.lua.globals().get("seen").unwrap();
5170        assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
5171        assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
5172        let surface = core.theme().surface;
5173        assert_eq!(
5174            seen.get::<u32>("up").unwrap(),
5175            surface.mix(Color::WHITE, 0.25).to_hex(),
5176            "a role source, raised toward the dark base's front"
5177        );
5178        assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
5179        let dl = core.output().0;
5180        assert!(
5181            dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
5182            "$a_deep painted the derived colour"
5183        );
5184
5185        // The parser refuses what the core could not name.
5186        let refused = |colors: &str| {
5187            LuaExtension::from_source(
5188                "bad",
5189                &format!(
5190                    "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
5191                ),
5192            )
5193            .err()
5194            .map(|e| e.to_string())
5195            .unwrap_or_default()
5196        };
5197        assert!(
5198            refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
5199                .contains("unknown verb \"glow\"")
5200        );
5201        assert!(
5202            refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
5203                .contains("mix takes a colour and a number")
5204        );
5205        assert!(
5206            refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
5207                .contains("number is a number")
5208        );
5209        assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
5210        assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
5211    }
5212
5213    /// A script's table is its own: its `$peach` is the host's until it
5214    /// declares one, its `grey` never reaches the host, and
5215    /// `env.set_tokens` from inside a view replaces the script's table for
5216    /// the nodes after the call.
5217    #[test]
5218    fn a_scripts_tokens_sit_over_the_hosts() {
5219        let mut ext = LuaExtension::from_source(
5220            "guest",
5221            r##"
5222                tokens = { colors = { grey = "#808080" } }
5223                function view(env)
5224                  first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5225                  if redeclare then
5226                    env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
5227                  end
5228                  second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5229                  return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
5230                end
5231            "##,
5232        )
5233        .unwrap();
5234        let mut core = Core::new();
5235        core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
5236        frame(&mut core, &mut ext);
5237        let first: Table = ext.lua.globals().get("first").unwrap();
5238        assert_eq!(
5239            first.get::<u32>("peach").unwrap(),
5240            0xffcc99ff,
5241            "the host's peach"
5242        );
5243        assert_eq!(
5244            first.get::<u32>("grey").unwrap(),
5245            0x808080ff,
5246            "its own grey"
5247        );
5248        assert!(
5249            core.token_lookup().color("grey").is_err(),
5250            "the guest's grey is not the host's"
5251        );
5252        assert!(core.take_warnings().is_empty());
5253
5254        ext.lua.globals().set("redeclare", true).unwrap();
5255        frame(&mut core, &mut ext);
5256        let second: Table = ext.lua.globals().get("second").unwrap();
5257        assert_eq!(
5258            second.get::<u32>("peach").unwrap(),
5259            0xffaa77ff,
5260            "its own peach now"
5261        );
5262        assert!(
5263            second.get::<Option<u32>>("grey").unwrap().is_none(),
5264            "replaced whole"
5265        );
5266        let a = core
5267            .output()
5268            .0
5269            .quads
5270            .iter()
5271            .find(|q| q.color == Color::hex(0xffaa77ff))
5272            .expect("painted the script's peach");
5273        assert_eq!(a.rect.w, 40.0);
5274        assert_eq!(
5275            core.token_lookup().color("peach"),
5276            Ok(Color::hex(0xffcc99ff)),
5277            "the host's table did not move"
5278        );
5279    }
5280
5281    /// A `$name` nothing declared warns once and leaves the slot at its
5282    /// default; a declared role name warns at the declaration.
5283    #[test]
5284    fn a_missing_token_warns_and_paints_nothing() {
5285        let mut ext = LuaExtension::from_source(
5286            "missing",
5287            r##"
5288                tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
5289                function view(env)
5290                  return column {
5291                    row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
5292                    row { key = "b", width = "$peach", height = 10, bg = "$gap" },
5293                    text("still here", { color = "$peech", size = "$gap" }),
5294                    text("still here", { size = 6 }),
5295                    text("still here"),
5296                  }
5297                end
5298            "##,
5299        )
5300        .unwrap();
5301        let mut core = Core::new();
5302        core.set_inspect(true);
5303        frame(&mut core, &mut ext);
5304        frame(&mut core, &mut ext);
5305        let ws = core.take_warnings();
5306        let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
5307        codes.sort();
5308        assert_eq!(
5309            codes,
5310            [
5311                kui_core::diag::RESERVED_TOKEN,
5312                kui_core::diag::UNKNOWN_TOKEN,
5313                kui_core::diag::UNKNOWN_TOKEN,
5314                kui_core::diag::UNKNOWN_TOKEN,
5315            ],
5316            "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
5317        );
5318        assert!(
5319            ws.iter().any(|w| w
5320                .message
5321                .contains("`$gap` is a length token, and this slot takes a color")),
5322            "{ws:?}"
5323        );
5324        // No solid quad was painted: both bgs were left out. The text with
5325        // the mistyped colour is still there, in the theme's foreground and
5326        // at the declared size — a typo hides nothing.
5327        let fg = core.theme().fg;
5328        let dl = core.output().0;
5329        assert!(
5330            dl.quads
5331                .iter()
5332                .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
5333        );
5334        let glyphs: Vec<_> = dl
5335            .quads
5336            .iter()
5337            .filter(|q| {
5338                matches!(
5339                    q.kind,
5340                    kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
5341                )
5342            })
5343            .collect();
5344        assert!(!glyphs.is_empty(), "the text painted");
5345        assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
5346        // At the declared 6 px size, not 0 and not the default: the same
5347        // text laid out with a literal `size = 6` is exactly as tall, and
5348        // one at the default size is taller. Compared to a control rather
5349        // than to a number, since a glyph's height at 6 px is the font's.
5350        let heights: Vec<f32> = core
5351            .nodes()
5352            .iter()
5353            .filter(|n| n.text.as_deref() == Some("still here"))
5354            .map(|n| n.rect.h)
5355            .collect();
5356        assert_eq!(heights.len(), 3, "{heights:?}");
5357        assert!(heights[0] > 0.0, "{heights:?}");
5358        assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
5359        assert!(heights[2] > heights[0], "and not the default: {heights:?}");
5360    }
5361
5362    /// AR14: the three slots a `$name` could not reach, and the stops it
5363    /// failed the frame from — a `min_width`, a line's `width`, a
5364    /// keyframe's `bg` and an entrance's `width` — resolve like any prop,
5365    /// and a miss leaves the slot at its default with one `unknown-token`.
5366    #[test]
5367    fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
5368        let mut ext = LuaExtension::from_source(
5369            "everywhere",
5370            r##"
5371                tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
5372                function view(env)
5373                  return column { pad = 4,
5374                    row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
5375                    row { key = "typo", width = 10, height = 10, min_width = "$nope" },
5376                    line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
5377                    row { key = "anim", width = 10, height = 10, transition = 100,
5378                          keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
5379                          enter = { width = "$nothing", bg = "$peach" } },
5380                  }
5381                end
5382            "##,
5383        )
5384        .unwrap();
5385        let mut core = Core::new();
5386        core.set_inspect(true);
5387        frame(&mut core, &mut ext);
5388        let nodes = core.nodes();
5389        let by = |label: &str| {
5390            nodes
5391                .iter()
5392                .find(|n| n.label.as_deref() == Some(label))
5393                .unwrap()
5394        };
5395        assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
5396        assert_eq!(
5397            by("typo").rect.w,
5398            10.0,
5399            "a miss leaves the row at its default"
5400        );
5401        let dl = core.output().0;
5402        let seg = dl
5403            .quads
5404            .iter()
5405            .find(|q| q.kind == kui_core::QuadKind::Segment)
5406            .expect("the line drew");
5407        assert_eq!(seg.color, Color::hex(0xffcc99ff));
5408        assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
5409        let ws = core.take_warnings();
5410        let mut names: Vec<String> = ws
5411            .iter()
5412            .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
5413            .map(|w| w.message.clone())
5414            .collect();
5415        names.sort();
5416        assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
5417        assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
5418        assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
5419        assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
5420    }
5421
5422    #[test]
5423    fn scripts_move_focus_with_the_verbs() {
5424        let mut ext = LuaExtension::from_source(
5425            "focus",
5426            r#"
5427                function view(env)
5428                  if cmd == "set" then env.set_focus(target)
5429                  elseif cmd == "blur" then env.blur()
5430                  elseif cmd == "next" then env.focus_next()
5431                  elseif cmd == "prev" then env.focus_prev() end
5432                  cmd = nil
5433                  -- `env.focus` is a value the host filled in before view()
5434                  -- ran, so it lags a verb called above by a frame;
5435                  -- `env.is_focused` is a call and answers about now.
5436                  seen_focus = env.focus
5437                  seen_live = env.is_focused(target or 0)
5438                  seen_window = env.focused
5439                  return column { key = "root", pad = 10,
5440                    row { key = "a", focusable = true, width = 50, height = 20 },
5441                    row { key = "b", focusable = true, width = 50, height = 20 },
5442                    row { key = "c", focusable = true, width = 50, height = 20 },
5443                  }
5444                end
5445            "#,
5446        )
5447        .unwrap();
5448        let root = Key::ROOT.str("root");
5449        let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
5450        let mut core = Core::new();
5451        let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
5452        let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
5453        let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
5454
5455        // The ring is the last finished frame's, so build one first.
5456        frame(&mut core, &mut ext);
5457        assert_eq!(core.focus(), None);
5458        assert_eq!(seen(&ext), None, "nil for no focus, not 0");
5459
5460        // Straight to a node by key, the imperative form of `key_focus`.
5461        ext.lua.globals().set("target", b.0 as i64).unwrap();
5462        cmd(&ext, "set");
5463        frame(&mut core, &mut ext);
5464        assert_eq!(core.focus(), Some(b));
5465        // `env.focus` is a snapshot the host wrote before view() ran, so it
5466        // still holds what focus was when the frame opened; `env.is_focused`
5467        // is a query into the live frame and sees the move at once.
5468        assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
5469        assert!(live(&ext), "the query does not");
5470        frame(&mut core, &mut ext);
5471        assert_eq!(
5472            seen(&ext),
5473            Some(b.0 as i64),
5474            "and the next frame carries it"
5475        );
5476
5477        // Tab and Shift-Tab, wrapping.
5478        cmd(&ext, "next");
5479        frame(&mut core, &mut ext);
5480        assert_eq!(core.focus(), Some(c));
5481        cmd(&ext, "next");
5482        frame(&mut core, &mut ext);
5483        assert_eq!(core.focus(), Some(a), "wraps");
5484        cmd(&ext, "prev");
5485        frame(&mut core, &mut ext);
5486        assert_eq!(core.focus(), Some(c), "wraps back");
5487
5488        cmd(&ext, "blur");
5489        frame(&mut core, &mut ext);
5490        assert_eq!(core.focus(), None);
5491        frame(&mut core, &mut ext);
5492        assert_eq!(seen(&ext), None);
5493
5494        // By label: the node's own `key` string, with no event from it
5495        // first (F5). `c` was never clicked, tabbed to or reported.
5496        ext.lua.globals().set("target", "c").unwrap();
5497        cmd(&ext, "set");
5498        frame(&mut core, &mut ext);
5499        assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
5500        assert!(live(&ext), "and is_focused(\"c\") answers about it");
5501        assert!(core.take_warnings().is_empty());
5502        // A label nothing declares is an error that names both spellings.
5503        ext.lua.globals().set("target", "nope").unwrap();
5504        cmd(&ext, "set");
5505        let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
5506        let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5507        ui.finish();
5508        assert!(
5509            e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
5510            "{e}"
5511        );
5512
5513        // `env.focused` is the window's focus, not the node's: it stays a
5514        // bool through all of the above, and follows the host env instead.
5515        assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
5516    }
5517
5518    /// Two nodes on one label under different parents: the first in tree
5519    /// order is the one focused, and the frame says so once.
5520    #[test]
5521    fn a_shared_label_resolves_to_the_first_and_warns() {
5522        let mut ext = LuaExtension::from_source(
5523            "dup",
5524            r#"
5525                function view(env)
5526                  if go then env.set_focus("item"); go = nil end
5527                  return column { key = "root",
5528                    row { row { key = "item", focusable = true, width = 50, height = 20 } },
5529                    row { row { key = "item", focusable = true, width = 50, height = 20 } },
5530                  }
5531                end
5532            "#,
5533        )
5534        .unwrap();
5535        let mut core = Core::new();
5536        frame(&mut core, &mut ext);
5537        ext.lua.globals().set("go", true).unwrap();
5538        frame(&mut core, &mut ext);
5539        assert_eq!(
5540            core.focus(),
5541            Some(Key::ROOT.str("root").index(0).str("item")),
5542            "the first, under the auto-keyed first row"
5543        );
5544        let ws = core.take_warnings();
5545        assert_eq!(ws.len(), 1, "{ws:?}");
5546        assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
5547        assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
5548    }
5549
5550    /// A script opens a context menu with `env.open_menu` and hears the
5551    /// chosen row as a `menu` event on the node it named (ADR 0017).
5552    #[test]
5553    fn scripts_open_a_menu_and_hear_the_row() {
5554        let mut ext = LuaExtension::from_source(
5555            "menu",
5556            r#"
5557                frames = 0
5558                function view(env)
5559                  frames = frames + 1
5560                  if frames == 2 then
5561                    opened = env.open_menu("card", 40, 30, {
5562                      { role = "copy" },
5563                      { role = "separator" },
5564                      -- The wire spelling the event reports, and the
5565                      -- snake one a script may have learned first.
5566                      { role = "selectAll" },
5567                      { role = "look_up" },
5568                      { label = "Wrap", checked = true },
5569                      { label = "Inspect", id = "inspect" },
5570                    })
5571                  end
5572                  return column {
5573                    column { key = "card", selectable = true, text("one", { size = 14 }) },
5574                  }
5575                end
5576            "#,
5577        )
5578        .unwrap();
5579        let mut core = Core::new();
5580        let frame = |core: &mut Core, ext: &mut LuaExtension| {
5581            let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5582            ui.set_origin(OriginId(1));
5583            ext.view(&Slot::root(), &mut ui).unwrap();
5584            ui.finish();
5585        };
5586        frame(&mut core, &mut ext);
5587        frame(&mut core, &mut ext);
5588        assert!(ext.lua.globals().get::<bool>("opened").unwrap());
5589        let roles: Vec<_> = core
5590            .menu()
5591            .expect("open")
5592            .items
5593            .iter()
5594            .map(|i| (i.role, i.checked))
5595            .collect();
5596        assert_eq!(
5597            roles,
5598            [
5599                (kui_core::MenuRole::Copy, false),
5600                (kui_core::MenuRole::Separator, false),
5601                (kui_core::MenuRole::SelectAll, false),
5602                (kui_core::MenuRole::LookUp, false),
5603                (kui_core::MenuRole::Custom, true),
5604                (kui_core::MenuRole::Custom, false),
5605            ]
5606        );
5607        frame(&mut core, &mut ext);
5608        // The row the tree reports is the row the pointer can press, and
5609        // the checked one reads as checked there.
5610        let tree = core.access_tree();
5611        assert_eq!(
5612            tree.nodes
5613                .iter()
5614                .find(|n| n.name.as_deref() == Some("Wrap"))
5615                .expect("the checked row")
5616                .checked,
5617            Some(true)
5618        );
5619        let row = tree
5620            .nodes
5621            .iter()
5622            .find(|n| n.name.as_deref() == Some("Inspect"))
5623            .expect("the menu drew")
5624            .rect;
5625        let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
5626        core.handle_input(InputEvent::CursorMoved(at));
5627        core.handle_input(InputEvent::mouse_down(1));
5628        let events = core.handle_input(InputEvent::mouse_up());
5629        assert_eq!(events.len(), 1, "{events:?}");
5630        assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
5631        assert_eq!(
5632            events[0].origin,
5633            OriginId(1),
5634            "posted with the origin that asked for the menu"
5635        );
5636    }
5637
5638    /// A `selectable` container scopes one selection over the runs inside
5639    /// it, and a script reads it back by the scope's label (ADR 0017).
5640    #[test]
5641    fn scripts_select_and_read_a_scope() {
5642        let mut ext = LuaExtension::from_source(
5643            "sel",
5644            r#"
5645                frames = 0
5646                function view(env)
5647                  frames = frames + 1
5648                  if frames > 1 then
5649                    before = env.selection_text()
5650                    took = env.select_all_in("card")
5651                    text_out = env.selection_text()
5652                    not_a_scope = env.select_all_in("plain")
5653                  end
5654                  return column {
5655                    column { key = "card", selectable = true,
5656                      text("one", { size = 14 }),
5657                      text("two", { size = 14 }) },
5658                    row { key = "plain", width = 10, height = 10 },
5659                  }
5660                end
5661            "#,
5662        )
5663        .unwrap();
5664        let mut core = Core::new();
5665        let frame = |core: &mut Core, ext: &mut LuaExtension| {
5666            let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5667            ui.set_origin(OriginId(1));
5668            ext.view(&Slot::root(), &mut ui).unwrap();
5669            ui.finish();
5670        };
5671        frame(&mut core, &mut ext);
5672        frame(&mut core, &mut ext);
5673        let g = ext.lua.globals();
5674        assert!(matches!(
5675            g.get::<mlua::Value>("before").unwrap(),
5676            mlua::Value::Nil
5677        ));
5678        assert!(g.get::<bool>("took").unwrap());
5679        assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
5680        assert!(
5681            !g.get::<bool>("not_a_scope").unwrap(),
5682            "a node that drew no text is not a scope"
5683        );
5684    }
5685
5686    /// A script turns a click into a caret with `env.text_hit` and a caret
5687    /// into a rect with `env.caret_rect` (backlog C18), both by the `line`
5688    /// row's label and across the runs inside it — answered, mid-build,
5689    /// from the frame that finished.
5690    #[test]
5691    fn scripts_map_points_to_bytes_on_a_line_of_runs() {
5692        let mut ext = LuaExtension::from_source(
5693            "hit",
5694            r#"
5695                frames = 0
5696                function view(env)
5697                  local mono = { size = 14, family = "mono" }
5698                  local w = env.measure_text("M", mono, 0).width
5699                  frames = frames + 1
5700                  -- A label nothing has declared yet is an error by name
5701                  -- (F5), so the first frame declares and the next asks.
5702                  if frames > 1 then
5703                    hit = env.text_hit("line", 7.2 * w, 5)
5704                    seam = env.caret_rect("line", 4)
5705                    far = env.text_hit("line", 390, 5)
5706                    none = env.caret_rect("plain", 0)
5707                  end
5708                  cell = w
5709                  return column {
5710                    row { key = "line",
5711                      text("let ", mono),
5712                      row { bg = 0x3b5bd455, text("value", mono) },
5713                      text(" = 1;", mono) },
5714                    row { key = "plain", width = 10, height = 10 },
5715                  }
5716                end
5717            "#,
5718        )
5719        .unwrap();
5720        let mut core = Core::new();
5721        let frame = |core: &mut Core, ext: &mut LuaExtension| {
5722            let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5723            ui.set_origin(OriginId(1));
5724            ext.view(&Slot::root(), &mut ui).unwrap();
5725            ui.finish();
5726        };
5727        frame(&mut core, &mut ext);
5728        let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
5729        assert!(
5730            matches!(hit, mlua::Value::Nil),
5731            "nothing drawn before the first frame"
5732        );
5733        frame(&mut core, &mut ext);
5734        let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
5735        assert_eq!(
5736            hit.get::<usize>("byte").unwrap(),
5737            7,
5738            "the fourth cell of \"value\""
5739        );
5740        assert_eq!(hit.get::<u32>("line").unwrap(), 0);
5741        let cell: f32 = ext.lua.globals().get("cell").unwrap();
5742        let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
5743        let x: f32 = seam.get("x").unwrap();
5744        assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
5745        assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
5746        let far: mlua::Table = ext.lua.globals().get("far").unwrap();
5747        // 14 is also what pins the prelude's `text` copying its options:
5748        // the three runs share one `mono` table, and before the copy they
5749        // were one table holding the last string, so the line was three
5750        // times " = 1;" and 15 long.
5751        assert_eq!(
5752            far.get::<usize>("byte").unwrap(),
5753            14,
5754            "the end, across the runs"
5755        );
5756        let none: mlua::Value = ext.lua.globals().get("none").unwrap();
5757        assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
5758    }
5759
5760    /// `cells { lines=, runs= }` is a terminal's screen as one node
5761    /// (backlog C20): the rows draw, a run colours its span, and the
5762    /// access tree reads the screen back.
5763    #[test]
5764    fn scripts_draw_a_screen_of_cells() {
5765        let mut ext = LuaExtension::from_source(
5766            "term",
5767            r#"
5768                function view(env)
5769                  return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
5770                    lines = { "hello world", "  bye" },
5771                    runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
5772                    cursor_at = { 1, 4 }, cursor_shape = "underline" } }
5773                end
5774            "#,
5775        )
5776        .unwrap();
5777        let mut core = Core::new();
5778        let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5779        ui.set_origin(OriginId(1));
5780        ext.view(&Slot::root(), &mut ui).unwrap();
5781        ui.finish();
5782        let (dl, _) = core.output();
5783        let glyphs = dl
5784            .quads
5785            .iter()
5786            .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
5787            .count();
5788        assert_eq!(glyphs, 13, "hello world + bye");
5789        let tree = core.access_tree();
5790        let term = tree
5791            .nodes
5792            .iter()
5793            .find(|n| n.role == kui_core::Role::Terminal)
5794            .expect("a terminal node");
5795        assert_eq!(term.value.as_deref(), Some("hello world\n  bye"));
5796    }
5797
5798    /// A `selectable` grid selects in cells, and the click count picks the
5799    /// grain — one a cell, two the word, three the whole row (ADR 0017,
5800    /// decision 4). Lua declares the scope and reads the result back
5801    /// through `env.selection_text`; the gesture itself is the core's, so
5802    /// what this pins is that a Lua-declared grid is a scope at all.
5803    #[test]
5804    fn a_lua_grid_selects_by_cell_word_and_row() {
5805        let mut ext = LuaExtension::from_source(
5806            "term",
5807            r#"
5808                function view(env)
5809                  said = env.selection_text()
5810                  return column { cells { key = "term", rows = 2, cols = 12,
5811                    size = 14, family = "mono", line_height = 20,
5812                    origin_line = 900, selectable = true,
5813                    lines = { "hello world", "bye there" } } }
5814                end
5815            "#,
5816        )
5817        .unwrap();
5818        let mut core = Core::new();
5819        let frame = |core: &mut Core, ext: &mut LuaExtension| {
5820            let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5821            ui.set_origin(OriginId(1));
5822            ext.view(&Slot::root(), &mut ui).unwrap();
5823            ui.finish();
5824        };
5825        frame(&mut core, &mut ext);
5826        let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
5827
5828        // The middle of (row, col), from the grid's own metrics.
5829        let w = core
5830            .measure_text(
5831                "M",
5832                &kui_core::TextStyle::new(14.0)
5833                    .family(kui_core::FontFamily::Mono)
5834                    .line_height(20.0),
5835                None,
5836            )
5837            .width;
5838        let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
5839        let click = |core: &mut Core, p: Vec2, clicks: u8| {
5840            core.handle_input(InputEvent::CursorMoved(p));
5841            core.handle_input(InputEvent::MouseDown {
5842                button: kui_core::MouseButton::Primary,
5843                clicks,
5844            });
5845            core.handle_input(InputEvent::MouseUp {
5846                button: kui_core::MouseButton::Primary,
5847            });
5848        };
5849
5850        click(&mut core, at(0, 8), 2);
5851        frame(&mut core, &mut ext);
5852        assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
5853
5854        click(&mut core, at(1, 1), 3);
5855        frame(&mut core, &mut ext);
5856        assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
5857    }
5858
5859    /// `env.cell_selection()` reads a grid's selection back the way
5860    /// `selection_ends()` reads a text's (backlog B1a): the ends as the
5861    /// drag made them, the lines absolute — row 1 of a screen whose row 0
5862    /// is line 900 is line 901 — and nil while the window's selection is
5863    /// not a grid's.
5864    #[test]
5865    fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
5866        let mut ext = LuaExtension::from_source(
5867            "term",
5868            r#"
5869                function view(env)
5870                  sel = env.cell_selection()
5871                  return column { cells { key = "term", rows = 2, cols = 12,
5872                    size = 14, family = "mono", line_height = 20,
5873                    origin_line = 900, selectable = true,
5874                    lines = { "hello world", "bye there" } } }
5875                end
5876            "#,
5877        )
5878        .unwrap();
5879        let mut core = Core::new();
5880        let frame = |core: &mut Core, ext: &mut LuaExtension| {
5881            let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5882            ui.set_origin(OriginId(1));
5883            ext.view(&Slot::root(), &mut ui).unwrap();
5884            ui.finish();
5885        };
5886        frame(&mut core, &mut ext);
5887        assert!(
5888            ext.lua
5889                .globals()
5890                .get::<mlua::Value>("sel")
5891                .unwrap()
5892                .is_nil(),
5893            "nothing selected yet"
5894        );
5895        let w = core
5896            .measure_text(
5897                "M",
5898                &kui_core::TextStyle::new(14.0)
5899                    .family(kui_core::FontFamily::Mono)
5900                    .line_height(20.0),
5901                None,
5902            )
5903            .width;
5904        let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
5905        core.handle_input(InputEvent::CursorMoved(at(1, 4)));
5906        core.handle_input(InputEvent::MouseDown {
5907            button: kui_core::MouseButton::Primary,
5908            clicks: 1,
5909        });
5910        core.handle_input(InputEvent::CursorMoved(at(0, 1)));
5911        core.handle_input(InputEvent::MouseUp {
5912            button: kui_core::MouseButton::Primary,
5913        });
5914        frame(&mut core, &mut ext);
5915        let sel: Table = ext.lua.globals().get("sel").unwrap();
5916        let end = |name: &str| -> (u64, usize) {
5917            let t: Table = sel.get(name).unwrap();
5918            (t.get("line").unwrap(), t.get("col").unwrap())
5919        };
5920        assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
5921        assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
5922        assert_eq!(
5923            sel.get::<i64>("node").unwrap(),
5924            core.key_of("term").unwrap().0 as i64
5925        );
5926        assert!(!sel.get::<bool>("block").unwrap());
5927    }
5928
5929    /// A span's `underline`, `strikethrough` and `bg` reach the core
5930    /// (backlog C22): the frame carries the solid quads beside the glyphs.
5931    #[test]
5932    fn spans_carry_their_decorations() {
5933        let mut ext = LuaExtension::from_source(
5934            "deco",
5935            r#"
5936                function view(env)
5937                  return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
5938                                      " = 1;" }, { size = 14, family = "mono" }) }
5939                end
5940            "#,
5941        )
5942        .unwrap();
5943        let mut core = Core::new();
5944        let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5945        ui.set_origin(OriginId(1));
5946        ext.view(&Slot::root(), &mut ui).unwrap();
5947        ui.finish();
5948        let (dl, _) = core.output();
5949        let solids = dl
5950            .quads
5951            .iter()
5952            .filter(|q| q.kind == kui_core::QuadKind::Solid)
5953            .count();
5954        assert_eq!(solids, 2, "a background and an underline");
5955    }
5956
5957    /// An underline's own colour and shape (backlog K4): `underline_color`
5958    /// and `underline_style` on a span, on a text's options, and a run's
5959    /// seventh entry and the shape bits on cells. A wave is segment quads
5960    /// in the underline's colour; a solid coloured line is one solid.
5961    #[test]
5962    fn underlines_have_a_colour_and_a_shape_of_their_own() {
5963        let mut ext = LuaExtension::from_source(
5964            "k4",
5965            r#"
5966                function view(env)
5967                  return column {
5968                    text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
5969                         { size = 14, family = "mono" }),
5970                    text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
5971                    cells { rows = 1, cols = 3, size = 14, family = "mono",
5972                            lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
5973                  }
5974                end
5975            "#,
5976        )
5977        .unwrap();
5978        let mut core = Core::new();
5979        let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5980        ui.set_origin(OriginId(1));
5981        ext.view(&Slot::root(), &mut ui).unwrap();
5982        ui.finish();
5983        let (dl, _) = core.output();
5984        let red = Color::hex(0xff0000ff);
5985        let segs: Vec<_> = dl
5986            .quads
5987            .iter()
5988            .filter(|q| q.kind == kui_core::QuadKind::Segment)
5989            .collect();
5990        assert!(
5991            segs.len() >= 6,
5992            "a wave under the span and an undercurl over the cells: {}",
5993            segs.len()
5994        );
5995        assert!(
5996            segs.iter().all(|q| q.color == red),
5997            "in the underline colour"
5998        );
5999        let solids: Vec<_> = dl
6000            .quads
6001            .iter()
6002            .filter(|q| q.kind == kui_core::QuadKind::Solid)
6003            .collect();
6004        assert_eq!(solids.len(), 1, "the text's solid line, coloured");
6005        assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
6006        // A shape nobody spells is refused where it is declared.
6007        let mut bad = LuaExtension::from_source(
6008            "k4bad",
6009            r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
6010        )
6011        .unwrap();
6012        let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6013        ui.set_origin(OriginId(1));
6014        let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
6015        assert!(err.contains("solid | wavy | dotted"), "{err}");
6016    }
6017
6018    /// `features = "liga=0"` reaches the shaper through the same schema
6019    /// row every binding reads (backlog C23): a text with it and one
6020    /// without are shaped twice.
6021    #[test]
6022    fn features_are_a_text_option() {
6023        let mut ext = LuaExtension::from_source(
6024            "features",
6025            r#"
6026                function view(env)
6027                  return row { text("fi ->", { size = 16 }),
6028                               text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
6029                end
6030            "#,
6031        )
6032        .unwrap();
6033        let mut core = Core::new();
6034        let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6035        ui.set_origin(OriginId(1));
6036        ext.view(&Slot::root(), &mut ui).unwrap();
6037        ui.finish();
6038        assert_eq!(core.text_cache_len(), 2);
6039    }
6040
6041    /// `text(s, opts)` copies its options. It used to write `type` and
6042    /// `value` into the table it was handed and return it, so a script
6043    /// that hoisted a style — `local mono = { size = 14 }` — and passed it
6044    /// to three texts built one table three times, showing the last string
6045    /// thrice. Found by the text-hit test above (backlog C18).
6046    #[test]
6047    fn a_style_table_shared_by_three_texts_is_three_texts() {
6048        let mut ext = LuaExtension::from_source(
6049            "shared",
6050            r#"
6051                local mono = { size = 14, family = "mono" }
6052                function view(env)
6053                  return row { text("a", mono), text("bb", mono), text("ccc", mono) }
6054                end
6055            "#,
6056        )
6057        .unwrap();
6058        let mut core = Core::new();
6059        let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6060        ui.set_origin(OriginId(1));
6061        ext.view(&Slot::root(), &mut ui).unwrap();
6062        ui.finish();
6063        let names: Vec<String> = core
6064            .access_tree()
6065            .nodes
6066            .iter()
6067            .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
6068            .collect();
6069        let joined = names.join("|");
6070        assert!(
6071            joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
6072            "three different texts, got {joined:?}"
6073        );
6074        assert_eq!(
6075            core.text_cache_len(),
6076            3,
6077            "three strings shaped, not one three times"
6078        );
6079    }
6080
6081    /// A script virtualizes a 10k-row list with nothing but
6082    /// `env.scroll_geometry`: it declares the rows crossing the window and
6083    /// two spacers holding the space of the rest, so the frame costs a
6084    /// screenful and the scrollbar still spans the whole list.
6085    #[test]
6086    fn scripts_slice_a_long_list_from_the_geometry() {
6087        let mut ext = LuaExtension::from_source(
6088            "virtual",
6089            r#"
6090                ROWS, ROW_H, built = 10000, 30, 0
6091                function view(env)
6092                  local g = env.scroll_geometry(list_key)
6093                  -- No layout yet: fall back to the window for one frame.
6094                  local h = g and g.h or 200
6095                  local top = g and g.offset.y or 0
6096                  local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
6097                  local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
6098                  local list = { key = "list", width = "grow", height = "grow",
6099                                 scroll_y = true }
6100                  if first > 0 then
6101                    list[#list + 1] = row { key = "lead", width = "grow",
6102                                            height = first * ROW_H }
6103                  end
6104                  for i = first, last - 1 do
6105                    list[#list + 1] = row { key = "row" .. i, width = "grow",
6106                                            height = ROW_H, bg = 0x282840ff }
6107                  end
6108                  if last < ROWS then
6109                    list[#list + 1] = row { key = "tail", width = "grow",
6110                                            height = (ROWS - last) * ROW_H }
6111                  end
6112                  built = last - first
6113                  return column(list)
6114                end
6115            "#,
6116        )
6117        .unwrap();
6118        let list = Key::ROOT.str("list");
6119        ext.lua.globals().set("list_key", list.0 as i64).unwrap();
6120        let mut core = Core::new();
6121        let frame = |core: &mut Core, ext: &mut LuaExtension| {
6122            let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6123            ui.set_origin(OriginId(1));
6124            ext.view(&Slot::root(), &mut ui).unwrap();
6125            ui.finish();
6126        };
6127
6128        frame(&mut core, &mut ext);
6129        frame(&mut core, &mut ext);
6130        let built: i64 = ext.lua.globals().get("built").unwrap();
6131        assert_eq!(built, 7, "200 / 30 rounded up");
6132
6133        // The spacers make it the same list: full travel, and "jump to the
6134        // end" lands on the last row even though it was never built.
6135        let g = core.scroll_geometry(list).expect("laid out");
6136        assert_eq!(g.content.h, 10_000.0 * 30.0);
6137        core.set_scroll(list, Vec2::new(0.0, 1e9));
6138        frame(&mut core, &mut ext);
6139        frame(&mut core, &mut ext);
6140        assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
6141        let built: i64 = ext.lua.globals().get("built").unwrap();
6142        assert_eq!(built, 7, "still a screenful at the far end");
6143    }
6144
6145    /// The same list as one call: `uniform_list` from the prelude owns the
6146    /// slicing, the two spacers and the row keys, and the script says what a
6147    /// row looks like. It names its container by label, which is why the
6148    /// queries had to answer for a name no frame has declared yet — the
6149    /// first frame asks before the container exists (backlog C25).
6150    #[test]
6151    fn the_prelude_virtualizes_a_long_list_in_one_call() {
6152        let mut ext = LuaExtension::from_source(
6153            "virtual",
6154            r#"
6155                ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
6156                function view(env)
6157                  built, first_built = 0, -1
6158                  return uniform_list(env,
6159                    { key = "list", rows = ROWS, row_h = ROW_H,
6160                      width = "grow", height = "grow" },
6161                    function(i)
6162                      built = built + 1
6163                      if first_built < 0 then first_built = i end
6164                      return column { fill = true, bg = 0x282840ff,
6165                                      on_click = { kind = "pick", row = i } }
6166                    end)
6167                end
6168            "#,
6169        )
6170        .unwrap();
6171        let list = Key::ROOT.str("list");
6172        let mut core = Core::new();
6173        let frame = |core: &mut Core, ext: &mut LuaExtension| {
6174            let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6175            ui.set_origin(OriginId(1));
6176            ext.view(&Slot::root(), &mut ui).unwrap();
6177            ui.finish();
6178        };
6179
6180        frame(&mut core, &mut ext);
6181        frame(&mut core, &mut ext);
6182        let built: i64 = ext.lua.globals().get("built").unwrap();
6183        assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
6184
6185        // The spacers make it the whole list, and a row keeps the key its
6186        // data index gives it however far the range has slid.
6187        let g = core.scroll_geometry(list).expect("laid out");
6188        assert_eq!(g.content.h, 10_000.0 * 30.0);
6189        core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
6190        frame(&mut core, &mut ext);
6191        frame(&mut core, &mut ext);
6192        let first: i64 = ext.lua.globals().get("first_built").unwrap();
6193        assert_eq!(first, 298, "two rows of overscan above row 300");
6194        let built: i64 = ext.lua.globals().get("built").unwrap();
6195        assert_eq!(built, 11, "a screenful with overscan on both sides now");
6196        // The row's own node carries the data index, which is the key a list
6197        // that built all ten thousand would have given it.
6198        assert!(
6199            core.access_tree()
6200                .nodes
6201                .iter()
6202                .any(|n| n.key == list.index(300).index(0)),
6203            "row 300 is not keyed by its data index"
6204        );
6205    }
6206
6207    /// ADR 0037: a Lua view draws an installed family by naming it, with
6208    /// no host door, in the frame that names it — the face the host's
6209    /// handle draws, not sans — and a name nothing matches warns.
6210    #[test]
6211    fn a_view_names_a_family_and_draws_in_it() {
6212        let mut ext = LuaExtension::from_source(
6213            "fam",
6214            r#"
6215                named, sans, missed = nil, nil, nil
6216                function view(env)
6217                  named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
6218                  sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
6219                  return column {
6220                    text("iiiWWW", { family = "Fixture Mono", size = 20 }),
6221                    text("x", { family = "No Such Family 7" }),
6222                  }
6223                end
6224            "#,
6225        )
6226        .unwrap();
6227        let mut core = Core::new();
6228        core.add_font_data(kui_core::testing::font_face(
6229            "Fixture Mono",
6230            400,
6231            false,
6232            true,
6233        ))
6234        .unwrap();
6235        let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6236        ui.set_origin(OriginId(1));
6237        ext.view(&Slot::root(), &mut ui).unwrap();
6238        ui.finish();
6239        let handle = core.add_system_font("Fixture Mono").unwrap();
6240        let expected = core
6241            .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
6242            .width;
6243        let g = ext.lua.globals();
6244        let (named, sans) = (
6245            g.get::<f32>("named").unwrap(),
6246            g.get::<f32>("sans").unwrap(),
6247        );
6248        assert_eq!(named, expected, "the face the host's handle draws");
6249        assert_ne!(named, sans, "and not sans");
6250        let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6251        assert_eq!(codes, ["unknown-family"]);
6252    }
6253
6254    /// DX22: the prelude's row spec, row reveal and divider, the three
6255    /// Rust gained as `uniform_list_with`, `reveal_row` and `splitter`.
6256    #[test]
6257    fn the_prelude_reveals_a_row_styles_rows_and_splits() {
6258        let mut ext = LuaExtension::from_source(
6259            "dx22",
6260            r#"
6261                target, scrolled, page = nil, nil, nil
6262                function view(env)
6263                  if target then scrolled = reveal_row(env, "list", target, 20) end
6264                  page = rows_in_view(env, "list", 20)
6265                  zero = rows_in_view(env, "list", 0)
6266                  return row { width = 300, height = 100,
6267                    uniform_list(env,
6268                      { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
6269                        row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
6270                      function(i) return text("row " .. i) end),
6271                    splitter(env, { key = "bar", on_drag = { kind = "split" } }),
6272                    column { width = "grow", height = "grow" },
6273                  }
6274                end
6275            "#,
6276        )
6277        .unwrap();
6278        let mut core = Core::new();
6279        let frame = |core: &mut Core, ext: &mut LuaExtension| {
6280            let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
6281            ui.set_origin(OriginId(1));
6282            ext.view(&Slot::root(), &mut ui).unwrap();
6283            ui.finish();
6284        };
6285        frame(&mut core, &mut ext);
6286        frame(&mut core, &mut ext);
6287        assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
6288        assert!(
6289            core.take_warnings().is_empty(),
6290            "every prop the three spell is known"
6291        );
6292
6293        ext.lua.globals().set("target", 40).unwrap();
6294        frame(&mut core, &mut ext);
6295        assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
6296        let list = core.key_of("list").unwrap();
6297        assert_eq!(
6298            core.scroll_offset(list),
6299            Vec2::new(0.0, 760.0),
6300            "row 40 to the middle"
6301        );
6302        // Built for that offset in the same frame, each row clickable
6303        // through its own node.
6304        core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
6305        core.handle_input(InputEvent::mouse_down(1));
6306        let up = core.handle_input(InputEvent::mouse_up());
6307        assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
6308
6309        // The bar sits at 200..204; dragging it reports its parent's split.
6310        core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
6311        core.handle_input(InputEvent::mouse_down(1));
6312        core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
6313        let end = core.handle_input(InputEvent::mouse_up());
6314        let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
6315        assert_eq!(d.ratio().x, 0.75);
6316
6317        // A row past the end, or no stride, scrolls nothing (backlog RG75).
6318        ext.lua.globals().set("target", 500).unwrap();
6319        frame(&mut core, &mut ext);
6320        assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
6321        assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
6322        assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
6323    }
6324
6325    /// The same clamp as the JSX widget's: a list that shrank while scrolled
6326    /// slices past its own new end, and an unclamped `first` builds a lead
6327    /// spacer taller than the whole list with no rows in it.
6328    #[test]
6329    fn a_uniform_list_that_shrank_lands_in_one_frame() {
6330        let mut ext = LuaExtension::from_source(
6331            "virtual",
6332            r#"
6333                ROWS, first_built = 200, -1
6334                function view(env)
6335                  first_built = -1
6336                  return uniform_list(env,
6337                    { key = "list", rows = ROWS, row_h = 20,
6338                      width = "grow", height = "grow" },
6339                    function(i)
6340                      if first_built < 0 then first_built = i end
6341                      return column { fill = true, bg = 0x282840ff }
6342                    end)
6343                end
6344            "#,
6345        )
6346        .unwrap();
6347        let list = Key::ROOT.str("list");
6348        let mut core = Core::new();
6349        let frame = |core: &mut Core, ext: &mut LuaExtension| {
6350            let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
6351            ui.set_origin(OriginId(1));
6352            ext.view(&Slot::root(), &mut ui).unwrap();
6353            ui.finish();
6354        };
6355        frame(&mut core, &mut ext);
6356        frame(&mut core, &mut ext);
6357        core.set_scroll(list, Vec2::new(0.0, 3_000.0));
6358        frame(&mut core, &mut ext);
6359        frame(&mut core, &mut ext);
6360        let deep: i64 = ext.lua.globals().get("first_built").unwrap();
6361        assert!(
6362            deep > 100,
6363            "expected to be deep in the list, built from {deep}"
6364        );
6365
6366        ext.lua.globals().set("ROWS", 10).unwrap();
6367        frame(&mut core, &mut ext);
6368        let g = core.scroll_geometry(list).expect("laid out");
6369        assert_eq!(
6370            g.content.h,
6371            10.0 * 20.0,
6372            "the content is the list it has now"
6373        );
6374        frame(&mut core, &mut ext);
6375        let first: i64 = ext.lua.globals().get("first_built").unwrap();
6376        assert_eq!(first, 0, "and every row of it is built");
6377        assert_eq!(core.scroll_offset(list).y, 0.0);
6378    }
6379
6380    /// The variable-height list's script (backlog C46): rows 0..100 are 20
6381    /// px and the rest 60, so the estimate the first screenful produces is
6382    /// badly wrong for the middle of the list — which is what makes the
6383    /// anchor observable. `TRANSITION` puts RG18's glide on the container.
6384    const VARIABLE_LIST: &str = r#"
6385        heights = row_heights(1000, 20)
6386        measured = 0
6387        function view(env)
6388          return list(env,
6389            { key = "list", heights = heights, width = "grow", height = "grow",
6390              transition = TRANSITION },
6391            function(i, width)
6392              measured = measured + 1
6393              if i < 100 then return 20 else return 60 end
6394            end,
6395            function(i)
6396              return column { width = "grow", height = "grow", bg = 0x282840ff,
6397                              label = "row", on_click = i }
6398            end)
6399        end
6400    "#;
6401
6402    /// One frame of `VARIABLE_LIST` at time `t`, 400 x 200.
6403    fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
6404        core.set_time(t);
6405        let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6406        ui.set_origin(OriginId(1));
6407        ext.view(&Slot::root(), &mut ui).unwrap();
6408        ui.finish();
6409    }
6410
6411    /// Which row is under `y`, by a click rather than by arithmetic — the
6412    /// question "did the content move" asks of the pixels.
6413    fn row_under(core: &mut Core, y: f32) -> Option<i64> {
6414        core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
6415        core.handle_input(InputEvent::mouse_down(1));
6416        let evs = core.handle_input(InputEvent::mouse_up());
6417        evs.first()?.payload.as_int()
6418    }
6419
6420    /// The Lua port of `widgets::list`'s crux: measuring the rows a frame
6421    /// builds moves the estimate under every row above the window, and the
6422    /// row under the top edge stays the row under the top edge.
6423    #[test]
6424    fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
6425        let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
6426        let list = Key::ROOT.str("list");
6427        let mut core = Core::new();
6428        variable_frame(&mut core, &mut ext, 0.0);
6429        variable_frame(&mut core, &mut ext, 0.0);
6430        let measured: i64 = ext.lua.globals().get("measured").unwrap();
6431        assert!(
6432            (10..=20).contains(&measured),
6433            "a screenful measured, not the list: {measured}"
6434        );
6435
6436        core.set_scroll(list, Vec2::new(0.0, 5_000.0));
6437        variable_frame(&mut core, &mut ext, 0.0);
6438        let settled = row_under(&mut core, 4.0);
6439        assert!(settled.is_some(), "nothing under the top edge");
6440        for n in 0..4 {
6441            variable_frame(&mut core, &mut ext, 0.0);
6442            assert_eq!(
6443                row_under(&mut core, 4.0),
6444                settled,
6445                "the content slid on frame {n} as the estimate moved"
6446            );
6447        }
6448        let g = core.scroll_geometry(list).expect("laid out");
6449        assert!(
6450            g.content.h > 1000.0 * 20.0 * 1.5,
6451            "the list learned it is longer: {}",
6452            g.content.h
6453        );
6454    }
6455
6456    /// RG18 through the Lua port: a long `set_scroll` on a container with a
6457    /// `transition` glides all the way to the row asked for, the heights of
6458    /// the rows it passes measured on the way.
6459    #[test]
6460    fn a_lua_list_glides_to_the_row_asked_for() {
6461        let mut ext =
6462            LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
6463                .unwrap();
6464        let list = Key::ROOT.str("list");
6465        let mut core = Core::new();
6466        let mut t = 0.0;
6467        variable_frame(&mut core, &mut ext, t);
6468        variable_frame(&mut core, &mut ext, t);
6469        let target = 400;
6470        let offset: f32 = ext
6471            .lua
6472            .load(format!("return heights:offset_of({target})"))
6473            .eval()
6474            .unwrap();
6475        core.set_scroll(list, Vec2::new(0.0, offset));
6476        for _ in 0..30 {
6477            t += 1.0 / 60.0;
6478            variable_frame(&mut core, &mut ext, t);
6479        }
6480        assert_eq!(row_under(&mut core, 4.0), Some(target));
6481    }
6482
6483    /// A script asks for a file dialog (backlog C51); the host takes the
6484    /// ask, answers it, and the answer comes back to the script that asked
6485    /// — its origin — as a `files` event with its tag.
6486    #[test]
6487    fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
6488        let mut ext = LuaExtension::from_source(
6489            "files",
6490            r#"
6491                ask, asked, again, waiting, heard = true, nil, nil, nil, nil
6492                function view(env)
6493                  if ask then
6494                    asked = env.request_files {
6495                      mode = "save", title = "Export", file_name = "notes.md",
6496                      filters = { { name = "Markdown", extensions = { "md" } } },
6497                      tag = "export",
6498                    }
6499                    again = env.request_files {}
6500                    waiting = env.awaiting_files()
6501                    ask = false
6502                  end
6503                  return column {}
6504                end
6505                function on_event(ev)
6506                  if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
6507                end
6508            "#,
6509        )
6510        .unwrap();
6511        let mut core = Core::new();
6512        variable_frame(&mut core, &mut ext, 0.0);
6513        let g = ext.lua.globals();
6514        assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
6515        assert!(
6516            !g.get::<bool>("again").unwrap(),
6517            "a second while it is out is not"
6518        );
6519        assert!(g.get::<bool>("waiting").unwrap());
6520
6521        let asks = core.take_file_requests();
6522        assert_eq!(asks.len(), 1);
6523        let d = &asks[0];
6524        assert_eq!(d.mode, kui_core::FileDialogMode::Save);
6525        assert_eq!(d.file_name.as_deref(), Some("notes.md"));
6526        assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
6527
6528        let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
6529        assert_eq!(evs.len(), 1);
6530        assert_eq!(
6531            evs[0].origin,
6532            OriginId(1),
6533            "the answer goes to the script that asked"
6534        );
6535        for e in &evs {
6536            ext.on_event(e);
6537        }
6538        let heard: String = ext.lua.globals().get("heard").unwrap();
6539        assert_eq!(heard, "/tmp/notes.md|export");
6540    }
6541
6542    /// The script is told what it got wrong, not handed a Lua error from
6543    /// inside the prelude.
6544    #[test]
6545    fn a_lua_list_names_what_it_is_missing() {
6546        let mut ext = LuaExtension::from_source(
6547            "bad",
6548            r#"
6549                function view(env)
6550                  ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
6551                    function() return column {} end)
6552                  return column {}
6553                end
6554            "#,
6555        )
6556        .unwrap();
6557        let mut core = Core::new();
6558        variable_frame(&mut core, &mut ext, 0.0);
6559        let err: String = ext.lua.globals().get("err").unwrap();
6560        assert!(err.contains("row_heights"), "{err}");
6561    }
6562
6563    /// A query answers for a label nothing declared, where a command says it
6564    /// is a name nothing answers to. The first frame of any view that slices
6565    /// by geometry asks before its container exists.
6566    #[test]
6567    fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
6568        let mut ext = LuaExtension::from_source(
6569            "queries",
6570            r#"
6571                function view(env)
6572                  geom = env.scroll_geometry("nothing")
6573                  off = env.scroll_offset("nothing")
6574                  hovered = env.is_hovered("nothing")
6575                  pressed = env.is_pressed("nothing")
6576                  focused = env.is_focused("nothing")
6577                  hit = env.text_hit("nothing", 1, 1)
6578                  caret = env.caret_rect("nothing", 0)
6579                  deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
6580                  refused_focus = not pcall(function() env.set_focus("nothing") end)
6581                  return column { width = "grow", height = "grow" }
6582                end
6583            "#,
6584        )
6585        .unwrap();
6586        let mut core = Core::new();
6587        let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
6588        ext.view(&Slot::root(), &mut ui).unwrap();
6589        ui.finish();
6590        let g = ext.lua.globals();
6591        assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
6592        assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
6593        assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
6594        assert!(!g.get::<bool>("hovered").unwrap());
6595        assert!(!g.get::<bool>("pressed").unwrap());
6596        assert!(!g.get::<bool>("focused").unwrap());
6597        let off: Table = g.get("off").unwrap();
6598        assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
6599        // `set_scroll` waits for the frame's end (backlog DX15), and the
6600        // frame not declaring the label is the warning that names it.
6601        assert!(g.get::<bool>("deferred").unwrap());
6602        let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6603        assert_eq!(codes, ["label-without-node"]);
6604        assert!(g.get::<bool>("refused_focus").unwrap());
6605    }
6606
6607    #[test]
6608    fn bad_script_reports_error_not_panic() {
6609        let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
6610        let mut core = Core::new();
6611        let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
6612        assert!(ext.view(&Slot::root(), &mut ui).is_err());
6613    }
6614}