Skip to main content

kui_lua/
lib.rs

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