Skip to main content

kui_lua/
lib.rs

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