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