Skip to main content

kui_lua/
lib.rs

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