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