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