Skip to main content

kui_ffi/
lib.rs

1//! C API for kui: a cdylib plus `include/kui.h`, and [`CExtension`] for loading a C plugin into a Rust host.
2//!
3//! kui splits a UI into a model that lays out and paints into a display
4//! list (`kui-core`), a renderer (`kui-wgpu`) and a windowed runner
5//! (`kui-native`). This crate puts the model, and optionally the runner,
6//! behind a flat C ABI: every `pub extern "C" fn` here is a `kui_*`
7//! symbol declared in `include/kui.h`, and the `Kui*` structs are the
8//! `repr(C)` mirrors the header declares field for field.
9//!
10//! Two kinds of program use it:
11//!
12//! - A **C, C++ or other-language host** links the cdylib (`libkui_ffi`)
13//!   and includes `kui.h`. It either hands a window to `kui_run` and
14//!   builds its view in a callback, or owns its own event loop and
15//!   renderer: it feeds input with `kui_input_*`, builds a frame between
16//!   [`kui_frame_begin`] and [`kui_frame_finish`], polls
17//!   [`kui_poll_event`] and draws [`kui_draw_data`].
18//! - A **Rust host** running `kui-native` loads a C shared library as a
19//!   guest extension through [`CExtension`]: the plugin draws into a slot
20//!   the host declares and gets its own events back.
21//!
22//! The C programs under `examples/c/` in the repository are working
23//! references for every call below.
24//!
25//! # Conventions
26//!
27//! - **Strings** cross as [`KuiStr`], a `(ptr, len)` pair of UTF-8 bytes
28//!   that is never NUL-terminated (`KUI_STR("literal")` in C). Invalid
29//!   UTF-8 is replaced. A string the library hands back is borrowed until
30//!   the next call of the same function on that context.
31//! - **Values.** A [`KuiValue`] from a `kui_value_*` constructor is yours
32//!   until you pass it to a function documented as consuming it (an
33//!   `on_click` payload, the value given to [`kui_value_map_set`]); free
34//!   anything else with [`kui_value_free`]. The payload on a polled event
35//!   is borrowed until the next [`kui_poll_event`].
36//! - **Panics never cross.** Every entry point catches panics and returns
37//!   its failure value instead (`false`, `0` or NULL). A NULL context is
38//!   answered the same way.
39//! - **ABI handshake.** Call [`kui_abi_version`] first and compare it for
40//!   equality with the header's `KUI_ABI_VERSION` (see
41//!   [`KUI_ABI_VERSION`]). Structs the library writes into your memory
42//!   lead with a `size` you set from `sizeof` (the `KUI_*_INIT`
43//!   initializers do), and the library writes no further than that.
44//! - **Coordinates** are logical pixels; draw data comes back in physical
45//!   pixels at the frame's `scale`.
46//!
47//! # A host's frame loop in C
48//!
49//! The shape without `kui_run`: the calls a host makes around a window and
50//! renderer of its own (the same calls work headless, which is how the C
51//! examples test themselves).
52//!
53//! ```c
54//! #include "kui.h"
55//!
56//! if (kui_abi_version() != KUI_ABI_VERSION) return 1;
57//! KuiCtx *ctx = kui_ctx_new();
58//! long long count = 0;
59//!
60//! for (;;) {
61//!     /* Input from the windowing library, in logical pixels. */
62//!     kui_input_cursor(ctx, mouse_x, mouse_y);
63//!     if (clicked) { kui_input_mouse(ctx, true, 1); kui_input_mouse(ctx, false, 1); }
64//!
65//!     /* What the UI emitted; a payload is borrowed until the next poll. */
66//!     KuiEvent ev = KUI_EVENT_INIT;
67//!     while (kui_poll_event(ctx, &ev)) {
68//!         const KuiValue *kind = kui_value_get(ev.payload, KUI_STR("kind"));
69//!         KuiStr s;
70//!         if (kind && kui_value_as_str(kind, &s) && kui_str_eq(s, "inc")) count++;
71//!     }
72//!
73//!     /* Build the frame from scratch. */
74//!     kui_set_time(ctx, now_seconds());
75//!     kui_frame_begin(ctx, width, height, scale);
76//!     KuiTheme t = KUI_THEME_INIT;
77//!     kui_theme(ctx, &t);
78//!     KuiSpec root = {.width = {KUI_GROW, 1}, .height = {KUI_GROW, 1},
79//!                     .main_align = KUI_CENTER, .cross_align = KUI_CENTER, .bg = t.bg};
80//!     kui_root(ctx, &root);
81//!     char buf[32];
82//!     snprintf(buf, sizeof buf, "%lld", count);
83//!     KuiTextStyle big = {.size = 56};
84//!     kui_text(ctx, KUI_STR(buf), &big);
85//!     KuiValue *inc = kui_value_map();
86//!     kui_value_map_set(inc, KUI_STR("kind"), kui_value_str(KUI_STR("inc")));
87//!     kui_button(ctx, KUI_STR("+1"), inc); /* consumes inc */
88//!     kui_frame_finish(ctx);
89//!
90//!     /* Draw it: one quad list, physical pixels, plus the glyph atlas to mirror. */
91//!     KuiDrawData dd = KUI_DRAW_DATA_INIT;
92//!     if (kui_draw_data(ctx, &dd)) {
93//!         if (dd.atlas_dirty) upload_atlas(dd.atlas_pixels, dd.atlas_size);
94//!         draw_quads(dd.quads, dd.quad_count, dd.clips);
95//!     }
96//!     if (!kui_animating(ctx)) wait_for_input();
97//! }
98//! kui_ctx_free(ctx);
99//! ```
100//!
101//! With the `runner` feature, `kui_run(title, view, on_event, user)` does
102//! all of this around a window of its own and calls `view` once per frame
103//! with a context to build into; `examples/c/apps/counter.c` is that
104//! program.
105//!
106//! # A C extension in a Rust app
107//!
108//! A plugin is a shared library exporting `kui_ext_abi` and `kui_ext_view`
109//! (and optionally `kui_ext_name`, `kui_ext_init`, `kui_ext_slots`,
110//! `kui_ext_on_event`, `kui_ext_free`); `examples/c/features/slots/panel.c`
111//! is one. The host loads it and declares where it draws:
112//!
113//! ```rust,no_run
114//! use kui_ffi::CExtension;
115//! use kui_native::{App, NodeSpec, Ui};
116//!
117//! struct Host;
118//!
119//! impl App for Host {
120//!     fn view(&mut self, ui: &mut Ui<'_>) {
121//!         ui.open(NodeSpec::row().fill());
122//!         // The plugin fills this position: `todos` is the namespace the
123//!         // host loaded it under, `panel` a slot the plugin lists.
124//!         ui.slot("todos/panel");
125//!         ui.close();
126//!     }
127//! }
128//!
129//! fn main() -> Result<(), Box<dyn std::error::Error>> {
130//!     // SAFETY: the plugin's code runs in this process. Loading it is
131//!     // trusting it as much as linking it would be.
132//!     let ext = unsafe { CExtension::open("target/debug/panel.so")? };
133//!     kui_native::app("host").extension_as("todos", ext).run(Host)
134//! }
135//! ```
136//!
137//! # Where to look
138//!
139//! Everything is exported flat at the crate root. By job:
140//!
141//! - Context: [`kui_ctx_new`], [`kui_ctx_free`], [`KuiCtx`].
142//! - Building a frame: [`kui_frame_begin`], [`kui_root`], [`kui_open`],
143//!   [`kui_open_keyed`], [`kui_text`], [`kui_close`], [`kui_frame_finish`];
144//!   [`KuiSpec`] and [`KuiTextStyle`] are what a node is built from.
145//! - Input and events: [`kui_input_cursor`], [`kui_input_mouse`],
146//!   [`kui_input_press`], [`kui_input_text`], [`kui_poll_event`],
147//!   [`KuiEvent`].
148//! - Values: [`kui_value_map`], [`kui_value_str`], [`kui_value_get`],
149//!   [`kui_value_as_str`], [`KuiValue`].
150//! - Drawing: [`kui_draw_data`], [`KuiDrawData`], [`KuiQuad`],
151//!   [`kui_set_subpixel_text`].
152//! - Widgets: [`kui_button`], [`kui_text_input`], [`kui_checkbox`],
153//!   [`kui_slider`], [`kui_select`].
154//! - Theme and metrics: [`kui_env_set_system`], [`kui_theme`],
155//!   [`KuiTheme`], [`kui_metrics`].
156//! - Windows: [`kui_window_declare`], [`kui_take_window_command`],
157//!   [`KuiWindowCommand`].
158//! - Accessibility: [`kui_access_tree`], [`KuiAccessNode`],
159//!   [`kui_input_access`].
160//! - Extensions: [`CExtension`], [`kui_ctx_add_extension`], [`kui_slot`],
161//!   [`kui_reply`].
162//! - Diagnostics: [`kui_set_diagnostics`], [`kui_take_warnings`],
163//!   [`kui_set_devtools`].
164//! - The windowed runner (feature `runner`): `kui_run`, `kui_run_with`,
165//!   [`KuiRunConfig`].
166//!
167//! # Features
168//!
169//! - `runner` (on by default): `kui_run` and `kui_run_with`, the windowed
170//!   runner from `kui-native`. A host that owns its own window and
171//!   renderer builds with `--no-default-features` and ships less than
172//!   half the library.
173//!
174//! The book: <https://kui-book.qxuken.dev>. Repository:
175//! <https://github.com/qxuken/kui> (design records live under `docs/adr`
176//! there).
177
178// Safe extern fns taking raw pointers is the point of this layer: every
179// entry point null-checks and catches panics instead of being `unsafe`.
180#![allow(clippy::not_unsafe_ptr_arg_deref)]
181
182// The other direction: a C shared library as a guest inside a host that
183// already owns the frame.
184mod ext;
185pub use ext::CExtension;
186
187#[macro_use]
188mod abi;
189mod access;
190mod convert;
191mod dialogs;
192mod focus;
193mod frame;
194mod input;
195mod menu;
196mod resources;
197#[cfg(feature = "runner")]
198mod run;
199mod scrolling;
200mod select;
201mod size;
202mod slots;
203mod types;
204mod value;
205mod widgets;
206mod windows;
207
208pub use abi::*;
209pub use access::*;
210pub use dialogs::*;
211pub use focus::*;
212pub use frame::*;
213pub use input::*;
214pub use menu::*;
215pub use resources::*;
216#[cfg(feature = "runner")]
217pub use run::*;
218pub use scrolling::*;
219pub use select::*;
220pub use size::*;
221pub use slots::*;
222pub use types::*;
223pub use value::*;
224pub use widgets::*;
225pub use windows::*;
226
227use convert::*;
228
229use std::collections::VecDeque;
230use std::ffi::c_void;
231use std::panic::{AssertUnwindSafe, catch_unwind};
232use std::rc::Rc;
233
234use kui_core::{
235    Align, Appearance, Assistive, AudioDevice, AudioEnv, Color, Core, DismissReason, Edges,
236    EditKey, EditOptions, Enter, FloatConfig, InputEvent, Key, Keyframe, Locale, Mods, MotionPref,
237    MouseButton, NodeSpec, OptionAsAlt, Rect, Size, Sizing, Span, SystemEnv, TextStyle, UiEvent,
238    Value, Vec2, WindowButton, WindowCommand, WindowConfig, WindowId, WindowKind,
239};
240
241// ---------------------------------------------------------------------------
242// Context lifecycle
243
244/// Creates a standalone context: a core of its own plus an event queue.
245///
246/// Free it with [`kui_ctx_free`]. Diagnostics start off; turn them on with
247/// [`kui_set_diagnostics`] in a development build. Returns NULL only if
248/// the core cannot be created.
249#[unsafe(no_mangle)]
250pub extern "C" fn kui_ctx_new() -> *mut KuiCtx {
251    guard(std::ptr::null_mut(), || {
252        let mut owned = Box::new(Core::new());
253        // A host driving its own frames opts into diagnostics explicitly,
254        // like everything else it drains; kui_run follows the runner's
255        // debug-build default.
256        owned.set_diagnostics(false);
257        let core: *mut Core = &mut *owned;
258        Box::into_raw(Box::new(KuiCtx {
259            core,
260            _owned: Some(owned),
261            events: Vec::new(),
262            last_payload: None,
263            last_edit_text: None,
264            fragment_source: String::new(),
265            fragment_draws: Vec::new(),
266            texture_draws: Vec::new(),
267            draws_current: false,
268            image_pixels: None,
269            last_warnings: Vec::new(),
270            pending_warnings: Vec::new(),
271            last_access: Default::default(),
272            last_runs: Vec::new(),
273            last_announcements: Vec::new(),
274            window_commands: VecDeque::new(),
275            menu_actions: VecDeque::new(),
276            menu_text: String::new(),
277            row_text: String::new(),
278            menu_accel: String::new(),
279            copy_text: String::new(),
280            menu_html: String::new(),
281            devtools_key: String::new(),
282            file_request: None,
283            file_filter_text: String::new(),
284            devtools_tab: String::new(),
285            selection_text: String::new(),
286            selection_html: String::new(),
287            font_families: Vec::new(),
288            system_fonts: Vec::new(),
289            nodes: None,
290            last_window_name: None,
291            slot_name: None,
292            slot_namespace: None,
293            slot_params: None,
294            extensions: Default::default(),
295            last_ext_error: String::new(),
296            host_ui: std::ptr::null_mut(),
297        }))
298    })
299}
300
301/// Frees a context from [`kui_ctx_new`], its queued events, every string
302/// it lent out and every extension it loaded. NULL is a no-op.
303#[unsafe(no_mangle)]
304pub extern "C" fn kui_ctx_free(ptr: *mut KuiCtx) {
305    if !ptr.is_null() {
306        drop(unsafe { Box::from_raw(ptr) });
307    }
308}
309
310// ---------------------------------------------------------------------------
311// Host environment
312
313/// Window facts for views to read: the display's refresh rate
314/// (`refresh_hz <= 0` is unknown) and whether the window has keyboard
315/// focus.
316///
317/// Sticky across frames; set it on change or every frame. A window that
318/// lost focus releases every key a sink was holding, and those releases
319/// are polled like any event.
320#[unsafe(no_mangle)]
321pub extern "C" fn kui_env_set(ptr: *mut KuiCtx, refresh_hz: f32, focused: bool) {
322    guard((), || {
323        if let Some(c) = unsafe { ctx(ptr) } {
324            c.core().env.refresh_hz = (refresh_hz > 0.0).then_some(refresh_hz);
325            c.core().set_focused(focused);
326        }
327    });
328}
329
330/// The OS's settings, for views and the theme to read: `appearance` is a
331/// `KUI_APPEARANCE_*`, `motion` a `KUI_MOTION_*` (0 is unknown for both),
332/// `accent` the accent colour as `0xRRGGBBAA` (0 is unknown) and `locale`
333/// a BCP-47 tag (empty is unknown; one over 31 bytes or not ASCII reads
334/// back as unknown).
335///
336/// Push them at startup and on the OS's change notification; they stick
337/// across frames. The palette [`kui_theme`] reports is re-derived at once.
338/// An out-of-range code is ignored. On a context handed to `kui_run_with`
339/// these become the window's pin over the OS's own reading; a zero field
340/// keeps following the OS.
341#[unsafe(no_mangle)]
342pub extern "C" fn kui_env_set_system(
343    ptr: *mut KuiCtx,
344    appearance: u32,
345    accent: u32,
346    motion: u32,
347    locale: KuiStr,
348) {
349    guard((), || {
350        if let Some(c) = unsafe { ctx(ptr) } {
351            // An out-of-range code is ignored rather than folded onto a
352            // real setting: a host built against a newer header says
353            // something this build has no name for.
354            let sys = SystemEnv {
355                appearance: Appearance::from_code(appearance).unwrap_or_default(),
356                motion: MotionPref::from_code(motion).unwrap_or_default(),
357                accent: (accent != 0).then(|| Color::hex(accent)),
358                locale: opt_str(locale).and_then(|tag| Locale::new(&tag)),
359                // Not this setter's: the four arguments are the settings,
360                // and the fifth reading has its own door below, so a host
361                // re-pushing the settings on an OS notification does not
362                // forget that a screen reader is attached.
363                assistive: c.core().env.system.assistive,
364            };
365            // `set_system` re-resolves the palette from what was just
366            // written, so a host that pushes the appearance and reads
367            // `kui_theme` back before its next frame sees the answer.
368            c.core().set_system(sys);
369        }
370    });
371}
372
373/// Whether assistive technology is listening, for views to read: a
374/// `KUI_ASSISTIVE_*` (0 is unknown, which is what a host with no
375/// accessibility bridge reports by never calling this).
376///
377/// A host bridging the platform's accessibility API pushes `LISTENING`
378/// when a client first asks for the tree and `NONE` if the platform says
379/// the client left. An out-of-range code is ignored.
380#[unsafe(no_mangle)]
381pub extern "C" fn kui_env_set_assistive(ptr: *mut KuiCtx, assistive: u32) {
382    guard((), || {
383        if let Some(c) = unsafe { ctx(ptr) } {
384            c.core().env.system.assistive = Assistive::from_code(assistive).unwrap_or_default();
385        }
386    });
387}
388
389/// What the host's audio output is doing, for views to read: `device` a
390/// `KUI_AUDIO_DEVICE_*` (0 is closed, which a host with no device reports
391/// by never calling this) and `live` the number of playbacks started or
392/// waiting on the open. A fact, not a command: nothing here closes the
393/// device. An out-of-range code is ignored.
394#[unsafe(no_mangle)]
395pub extern "C" fn kui_env_set_audio(ptr: *mut KuiCtx, device: u32, live: u32) {
396    guard((), || {
397        if let Some(c) = unsafe { ctx(ptr) } {
398            c.core().env.audio = AudioEnv {
399                device: AudioDevice::from_code(device).unwrap_or_default(),
400                live,
401            };
402        }
403    });
404}
405
406/// This window's palette as of the current or last frame: one
407/// `0xRRGGBBAA` per role, derived from what [`kui_env_set_system`] reported
408/// unless the host pinned something with [`kui_theme_set_accent`] or
409/// [`kui_theme_set`].
410///
411/// The stock widgets, the focus ring, the scrollbars and any text with a
412/// zero `color` already follow it. Read it for paint of your own:
413/// `KuiTheme t = KUI_THEME_INIT; kui_theme(ctx, &t);` then
414/// `spec.bg = t.surface`.
415///
416/// False for a bad context, a NULL `out`, or a `size` below the first
417/// ABI's layout.
418#[unsafe(no_mangle)]
419pub extern "C" fn kui_theme(ptr: *mut KuiCtx, out: *mut KuiTheme) -> bool {
420    guard(false, || {
421        let Some(c) = (unsafe { ctx(ptr) }) else {
422            return false;
423        };
424        let t = *c.core().theme();
425        write_out(
426            out,
427            KuiTheme {
428                appearance: t.appearance.code(),
429                disabled_opacity: t.disabled_opacity,
430                bg: t.bg.to_hex(),
431                surface: t.surface.to_hex(),
432                raised: t.raised.to_hex(),
433                sunken: t.sunken.to_hex(),
434                border: t.border.to_hex(),
435                border_strong: t.border_strong.to_hex(),
436                fg: t.fg.to_hex(),
437                muted: t.muted.to_hex(),
438                faint: t.faint.to_hex(),
439                accent: t.accent.to_hex(),
440                accent_hover: t.accent_hover.to_hex(),
441                accent_pressed: t.accent_pressed.to_hex(),
442                on_accent: t.on_accent.to_hex(),
443                accent_soft: t.accent_soft.to_hex(),
444                selection: t.selection.to_hex(),
445                focus_ring: t.focus_ring.to_hex(),
446                hover: t.hover.to_hex(),
447                pressed: t.pressed.to_hex(),
448                success: t.success.to_hex(),
449                warning: t.warning.to_hex(),
450                danger: t.danger.to_hex(),
451                scrollbar: t.scrollbar.to_hex(),
452                scrollbar_active: t.scrollbar_active.to_hex(),
453                ..Default::default()
454            },
455        )
456    })
457}
458
459/// Keeps following the OS's light or dark base but paints `accent`
460/// (`0xRRGGBBAA`) instead of the OS's accent; zero goes back to the OS's.
461///
462/// Everything derived from the accent moves with it: a button's hover and
463/// pressed shades, the label on it, the selection tint and the focus ring.
464#[unsafe(no_mangle)]
465pub extern "C" fn kui_theme_set_accent(ptr: *mut KuiCtx, accent: u32) {
466    guard((), || {
467        if let Some(c) = unsafe { ctx(ptr) } {
468            match accent {
469                0 => c.core().derive_theme(),
470                hex => c.core().set_accent(Color::hex(hex)),
471            }
472        }
473    })
474}
475
476/// Pins the whole palette to exactly these colours, following neither the
477/// OS's appearance nor its accent; NULL goes back to deriving both.
478///
479/// Every field is read (`size` is ignored), so start from [`kui_theme`]
480/// and change the roles you mean to change: a zeroed role is transparent,
481/// not "leave it".
482#[unsafe(no_mangle)]
483pub extern "C" fn kui_theme_set(ptr: *mut KuiCtx, theme: *const KuiTheme) {
484    guard((), || {
485        if let Some(c) = unsafe { ctx(ptr) } {
486            match unsafe { theme.as_ref() } {
487                None => c.core().derive_theme(),
488                Some(t) => c.core().set_theme(theme_of(t)),
489            }
490        }
491    })
492}
493
494/// Declares the named colours and lengths of the calling origin: the
495/// host's outside a plugin's view, the plugin's own inside `kui_ext_view`.
496///
497/// Replaces the table whole, so an app whose lengths follow a viewport
498/// tier declares again on a resize. A name a theme or metrics role owns is
499/// dropped with a `reserved-token` warning. Either array may be NULL with
500/// a zero count. A C spec carries plain numbers, so read a token back with
501/// [`kui_token_color`] or [`kui_token_length`] and write the value; the
502/// table gives the name to the devtools inspector, to guests reading the
503/// host's vocabulary, and to a Lua panel referencing `"$name"`.
504#[unsafe(no_mangle)]
505pub extern "C" fn kui_tokens_set(
506    ptr: *mut KuiCtx,
507    colors: *const KuiColorToken,
508    color_count: usize,
509    lengths: *const KuiLengthToken,
510    length_count: usize,
511) {
512    guard((), || {
513        let Some(c) = (unsafe { ctx(ptr) }) else {
514            return;
515        };
516        let mut t = kui_core::Tokens::new();
517        if !colors.is_null() {
518            for tok in unsafe { std::slice::from_raw_parts(colors, color_count) } {
519                t = t.color_themed(
520                    kstr(tok.name).into_owned(),
521                    Color::hex(tok.light),
522                    Color::hex(tok.dark),
523                );
524            }
525        }
526        if !lengths.is_null() {
527            for tok in unsafe { std::slice::from_raw_parts(lengths, length_count) } {
528                t = t.length(kstr(tok.name).into_owned(), tok.value);
529            }
530        }
531        c.core().set_tokens(t);
532    })
533}
534
535/// Adds derived colour tokens to the calling origin's table, after
536/// [`kui_tokens_set`]: each a name, the colour token or theme role it
537/// derives from, and a chain of ops applied in order.
538///
539/// The ops: `KUI_OP_LIFT` and `KUI_OP_DARKEN` move toward white or black
540/// by `t`, `KUI_OP_RAISE` toward the front of the base in effect,
541/// `KUI_OP_ALPHA` sets the alpha, `KUI_OP_MIX` mixes toward the token
542/// `other` names, and `KUI_OP_READABLE` moves toward black or white until
543/// the contrast ratio `t` against `other` is met. A source that is neither
544/// a token declared before it nor a role drops that token with an
545/// `unknown-token` warning. Returns false, adding nothing, for a malformed
546/// op: one past `KUI_OP_READABLE`, or `other` given to a verb that takes
547/// none or missing from one that does.
548#[unsafe(no_mangle)]
549pub extern "C" fn kui_tokens_derive(
550    ptr: *mut KuiCtx,
551    derived: *const KuiDerivedToken,
552    count: usize,
553) -> bool {
554    guard(false, || {
555        let Some(c) = (unsafe { ctx(ptr) }) else {
556            return false;
557        };
558        if derived.is_null() || count == 0 {
559            return true;
560        }
561        let mut t = c.core().tokens().cloned().unwrap_or_default();
562        for d in unsafe { std::slice::from_raw_parts(derived, count) } {
563            let ops = if d.ops.is_null() {
564                &[][..]
565            } else {
566                unsafe { std::slice::from_raw_parts(d.ops, d.op_count) }
567            };
568            let mut chain = Vec::with_capacity(ops.len());
569            for op in ops {
570                let Some(verb) = kui_core::ColorOp::VERBS.get(op.op as usize) else {
571                    return false;
572                };
573                let other = kstr(op.other);
574                let other = (!other.is_empty()).then_some(&*other);
575                let Some(step) = kui_core::ColorOp::parse(verb, other, op.t) else {
576                    return false;
577                };
578                chain.push(step);
579            }
580            t = t.derive(kstr(d.name).into_owned(), &kstr(d.from), chain);
581        }
582        c.core().set_tokens(t);
583        true
584    })
585}
586
587/// A colour token by name, resolved for this frame's appearance, as
588/// `0xRRGGBBAA` through `out`. The calling origin's table is tried first,
589/// then the host's; a theme role's name (`surface`) answers with the role.
590/// False for a name nothing declared or one that is a length, which also
591/// raises `unknown-token` once per name.
592#[unsafe(no_mangle)]
593pub extern "C" fn kui_token_color(ptr: *mut KuiCtx, name: KuiStr, out: *mut u32) -> bool {
594    guard(false, || {
595        let Some(c) = (unsafe { ctx(ptr) }) else {
596            return false;
597        };
598        let Some(out) = (unsafe { out.as_mut() }) else {
599            return false;
600        };
601        let name = kstr(name);
602        match c.core().token_lookup().color(&name) {
603            Ok(col) => {
604                *out = col.to_hex();
605                true
606            }
607            Err(e) => {
608                c.core().warn_unknown_token(&e);
609                false
610            }
611        }
612    })
613}
614
615/// A length token by name, in logical px; a metrics role's name
616/// (`radius`) answers with the metric. False and `unknown-token` as for
617/// [`kui_token_color`].
618#[unsafe(no_mangle)]
619pub extern "C" fn kui_token_length(ptr: *mut KuiCtx, name: KuiStr, out: *mut f32) -> bool {
620    guard(false, || {
621        let Some(c) = (unsafe { ctx(ptr) }) else {
622            return false;
623        };
624        let Some(out) = (unsafe { out.as_mut() }) else {
625            return false;
626        };
627        let name = kstr(name);
628        match c.core().token_lookup().length(&name) {
629            Ok(v) => {
630                *out = v;
631                true
632            }
633            Err(e) => {
634                c.core().warn_unknown_token(&e);
635                false
636            }
637        }
638    })
639}
640
641/// The sizes the stock widgets are built from, in logical px.
642/// `KuiMetrics m = KUI_METRICS_INIT; kui_metrics(ctx, &m);` then
643/// `spec.radius = m.radius` makes a control of your own agree with the
644/// stock ones. False for a bad context, a NULL `out`, or a `size` below
645/// the first ABI's layout.
646#[unsafe(no_mangle)]
647pub extern "C" fn kui_metrics(ptr: *mut KuiCtx, out: *mut KuiMetrics) -> bool {
648    guard(false, || {
649        let Some(c) = (unsafe { ctx(ptr) }) else {
650            return false;
651        };
652        let m = KuiMetrics::of(c.core().metrics());
653        write_out(out, m)
654    })
655}
656
657/// Makes these the metrics every stock widget from the next node on is
658/// built from; NULL restores the stock set. Start from [`kui_metrics`] and
659/// change the fields you mean to change: a zeroed metric is zero, not
660/// "leave it". Nothing in the OS is followed; density is the host's call.
661#[unsafe(no_mangle)]
662pub extern "C" fn kui_metrics_set(ptr: *mut KuiCtx, metrics: *const KuiMetrics) {
663    guard((), || {
664        if let Some(c) = unsafe { ctx(ptr) } {
665            match unsafe { metrics.as_ref() } {
666                None => c.core().set_metrics(kui_core::Metrics::default()),
667                Some(m) => c.core().set_metrics(m.to_core()),
668            }
669        }
670    })
671}
672
673/// A `KuiTheme` the host filled, as a core [`kui_core::Theme`]. An
674/// appearance code past the end is `unknown`, the way every other code is.
675fn theme_of(t: &KuiTheme) -> kui_core::Theme {
676    kui_core::Theme {
677        appearance: Appearance::from_code(t.appearance).unwrap_or_default(),
678        disabled_opacity: t.disabled_opacity,
679        bg: Color::hex(t.bg),
680        surface: Color::hex(t.surface),
681        raised: Color::hex(t.raised),
682        sunken: Color::hex(t.sunken),
683        border: Color::hex(t.border),
684        border_strong: Color::hex(t.border_strong),
685        fg: Color::hex(t.fg),
686        muted: Color::hex(t.muted),
687        faint: Color::hex(t.faint),
688        accent: Color::hex(t.accent),
689        accent_hover: Color::hex(t.accent_hover),
690        accent_pressed: Color::hex(t.accent_pressed),
691        on_accent: Color::hex(t.on_accent),
692        accent_soft: Color::hex(t.accent_soft),
693        selection: Color::hex(t.selection),
694        focus_ring: Color::hex(t.focus_ring),
695        hover: Color::hex(t.hover),
696        pressed: Color::hex(t.pressed),
697        success: Color::hex(t.success),
698        warning: Color::hex(t.warning),
699        danger: Color::hex(t.danger),
700        scrollbar: Color::hex(t.scrollbar),
701        scrollbar_active: Color::hex(t.scrollbar_active),
702    }
703}
704
705/// The frame clock for transitions, in monotonic seconds from any origin.
706/// Set it before each [`kui_frame_begin`]; a host that never does sees
707/// transitions snap to their targets.
708#[unsafe(no_mangle)]
709pub extern "C" fn kui_set_time(ptr: *mut KuiCtx, now_secs: f64) {
710    guard((), || {
711        if let Some(c) = unsafe { ctx(ptr) } {
712            c.core().set_time(now_secs);
713        }
714    });
715}
716
717/// True when the last frame left a transition mid-flight: draw another
718/// frame without waiting for input.
719#[unsafe(no_mangle)]
720pub extern "C" fn kui_animating(ptr: *mut KuiCtx) -> bool {
721    guard(false, || {
722        unsafe { ctx(ptr) }.is_some_and(|c| c.core().animating())
723    })
724}
725
726/// Why the last frame wants another, as `KUI_OWED_*` bits:
727/// [`kui_animating`] taken apart by kind. A host draws another frame for
728/// any of them; a test masks `KUI_OWED_CYCLE` off to wait for transitions
729/// to settle under a keyframe cycle that never will.
730#[unsafe(no_mangle)]
731pub extern "C" fn kui_owed(ptr: *mut KuiCtx) -> u32 {
732    guard(0, || {
733        unsafe { ctx(ptr) }.map_or(0, |c| {
734            let o = c.core().owed();
735            (o.transition as u32 * KUI_OWED_TRANSITION)
736                | (o.cycle as u32 * KUI_OWED_CYCLE)
737                | (o.depart as u32 * KUI_OWED_DEPART)
738                | (o.requested as u32 * KUI_OWED_REQUESTED)
739                | (o.autoscroll as u32 * KUI_OWED_AUTOSCROLL)
740                | (o.scroll as u32 * KUI_OWED_SCROLL)
741        })
742    })
743}
744
745/// Turns the trace of why frames run on or off: the digest
746/// [`kui_frame_unchanged`] compares. [`kui_frame_cause`] is kept either
747/// way.
748#[unsafe(no_mangle)]
749pub extern "C" fn kui_set_frame_trace(ptr: *mut KuiCtx, on: bool) {
750    guard((), || {
751        if let Some(c) = unsafe { ctx(ptr) } {
752            c.core().set_frame_trace(on);
753        }
754    })
755}
756
757/// The `KUI_FRAME_CAUSE_*` bits of the frame being built, or between
758/// frames the last one's.
759#[unsafe(no_mangle)]
760pub extern "C" fn kui_frame_cause(ptr: *mut KuiCtx) -> u32 {
761    guard(0, || {
762        unsafe { ctx(ptr) }.map_or(0, |c| c.core().frame_cause().bits())
763    })
764}
765
766/// Adds `KUI_FRAME_CAUSE_*` bits to the next frame's reasons: the host's
767/// own (a wake, a resize, a blink) beside the input the `kui_input_*`
768/// calls record.
769#[unsafe(no_mangle)]
770pub extern "C" fn kui_note_frame_cause(ptr: *mut KuiCtx, cause: u32) {
771    guard((), || {
772        if let Some(c) = unsafe { ctx(ptr) } {
773            c.core()
774                .note_frame_cause(kui_core::FrameCause::from_bits(cause));
775        }
776    })
777}
778
779/// 1 when the last finished frame drew exactly what the one before drew,
780/// 0 when not, -1 when untraced ([`kui_set_frame_trace`]) or on the first
781/// traced frame.
782#[unsafe(no_mangle)]
783pub extern "C" fn kui_frame_unchanged(ptr: *mut KuiCtx) -> i32 {
784    guard(-1, || {
785        unsafe { ctx(ptr) }.map_or(-1, |c| match c.core().frame_unchanged() {
786            Some(same) => same as i32,
787            None => -1,
788        })
789    })
790}
791
792/// Rasterizes outline glyphs as LCD subpixel coverage
793/// (`KUI_QUAD_GLYPH_SUBPIXEL` quads, the atlas's RGB being per-channel
794/// coverage) instead of alpha masks. Only for a renderer that blends per
795/// channel (dual-source blending); flipping it re-rasterizes every glyph.
796#[unsafe(no_mangle)]
797pub extern "C" fn kui_set_subpixel_text(ptr: *mut KuiCtx, on: bool) {
798    guard((), || {
799        if let Some(c) = unsafe { ctx(ptr) } {
800            c.core().set_subpixel_text(on);
801        }
802    });
803}
804
805/// Byte budget for the shaped-text cache: past it the least recently drawn
806/// entries are evicted at the start of the next frame, never what the last
807/// frame drew. Default 64 MB (`DEFAULT_TEXT_CACHE_BYTES`).
808#[unsafe(no_mangle)]
809pub extern "C" fn kui_set_text_cache_budget(ptr: *mut KuiCtx, bytes: usize) {
810    guard((), || {
811        if let Some(c) = unsafe { ctx(ptr) } {
812            c.core().set_text_cache_budget(bytes);
813        }
814    });
815}
816
817/// What the shaped-text cache holds, in the estimated bytes the budget is
818/// charged against.
819#[unsafe(no_mangle)]
820pub extern "C" fn kui_text_cache_bytes(ptr: *mut KuiCtx) -> usize {
821    guard(0, || {
822        unsafe { ctx(ptr) }.map_or(0, |c| c.core().text_cache_bytes())
823    })
824}
825
826/// Drains the warnings the core raised since the last call (silent
827/// misconfigurations it noticed while finishing frames, each once) into
828/// `out`, up to `cap` (the rest wait for the next call), and returns the
829/// count. The strings stay valid
830/// until the next call on this context. A standalone context starts with
831/// the checks off ([`kui_set_diagnostics`] turns them on); `kui_run`
832/// prints them to stderr itself in debug builds.
833#[unsafe(no_mangle)]
834pub extern "C" fn kui_take_warnings(ptr: *mut KuiCtx, out: *mut KuiWarning, cap: usize) -> usize {
835    guard(0, || {
836        let Some(c) = (unsafe { ctx(ptr) }) else {
837            return 0;
838        };
839        if out.is_null() || cap == 0 {
840            return 0;
841        }
842        // Drained from the core into the context's queue, and handed out
843        // from there `cap` at a time: the rest wait, as kui.h says, where
844        // they were dropped.
845        let raised = c.core().take_warnings();
846        c.pending_warnings.extend(raised);
847        let n = c.pending_warnings.len().min(cap);
848        c.last_warnings = c.pending_warnings.drain(..n).collect();
849        for (i, w) in c.last_warnings.iter().enumerate() {
850            let s = |s: &str| KuiStr {
851                ptr: s.as_ptr(),
852                len: s.len(),
853            };
854            unsafe {
855                out.add(i).write(KuiWarning {
856                    code: s(w.code),
857                    key: w.key.0,
858                    message: s(&w.message),
859                })
860            };
861        }
862        n
863    })
864}
865
866/// Turns the diagnostic checks behind [`kui_take_warnings`] on or off. Off
867/// by default for a standalone context; a development build opts in.
868#[unsafe(no_mangle)]
869pub extern "C" fn kui_set_diagnostics(ptr: *mut KuiCtx, on: bool) {
870    guard((), || {
871        if let Some(c) = unsafe { ctx(ptr) } {
872            c.core().set_diagnostics(on);
873        }
874    });
875}
876
877/// Turns the core's devtools panel on or off: the event stream, the
878/// runtime's facts and the tree, drawn by the core beside the host's tree
879/// (where [`kui_set_devtools_dock`] says). Its controls and its
880/// `Ctrl+Shift+<letter>` chords are handled inside the `kui_input_*`
881/// calls, so nothing of it reaches the host's events. `KUI_DEVTOOLS=1` in
882/// the environment makes the same call for a window `kui_run` opens; a
883/// headless context never reads it.
884#[unsafe(no_mangle)]
885pub extern "C" fn kui_set_devtools(ptr: *mut KuiCtx, on: bool) {
886    guard((), || {
887        if let Some(c) = unsafe { ctx(ptr) } {
888            c.core().set_devtools(on);
889        }
890    });
891}
892
893/// Where the devtools panel sits: `"left"`, `"right"`, `"bottom"`,
894/// `"window"` (one of its own, named `kui-devtools`, opened through an
895/// ordinary `KUI_CMD_OPEN` that the host builds nothing into) or `"off"`
896/// (hidden, chords still live); `"side"` means the right. Returns false
897/// for any other word.
898#[unsafe(no_mangle)]
899pub extern "C" fn kui_set_devtools_dock(ptr: *mut KuiCtx, dock: KuiStr) -> bool {
900    guard(false, || {
901        let Some(c) = (unsafe { ctx(ptr) }) else {
902            return false;
903        };
904        let Some(dock) = kui_core::DevtoolsDock::parse(&kstr(dock)) else {
905            return false;
906        };
907        c.core().set_devtools_dock(dock);
908        true
909    })
910}
911
912/// Whether the devtools panel is on.
913#[unsafe(no_mangle)]
914pub extern "C" fn kui_devtools(ptr: *mut KuiCtx) -> bool {
915    guard(false, || {
916        unsafe { ctx(ptr) }.is_some_and(|c| c.core().devtools())
917    })
918}
919
920/// Where the devtools panel sits, as a word [`kui_set_devtools_dock`]
921/// takes (`"right"` for the side); the string is static. False on a bad
922/// context.
923#[unsafe(no_mangle)]
924pub extern "C" fn kui_devtools_dock(ptr: *mut KuiCtx, out: *mut KuiStr) -> bool {
925    guard(false, || {
926        let Some(c) = (unsafe { ctx(ptr) }) else {
927            return false;
928        };
929        let name = c.core().devtools_dock().name();
930        if let Some(out) = unsafe { out.as_mut() } {
931            *out = KuiStr {
932                ptr: name.as_ptr(),
933                len: name.len(),
934            };
935        }
936        true
937    })
938}
939
940/// Where the last frame laid the host out in its window, in logical px:
941/// the whole window with the devtools panel off or in its own window, the
942/// pane beside the dock otherwise, and zeros before the first frame.
943/// Scaled by the frame's scale it separates the host's quads from the
944/// dock's in [`kui_draw_data`], except the root's background, which also
945/// fills the window beneath the pane. False on a bad context, a NULL
946/// `out` or a short `size`.
947#[unsafe(no_mangle)]
948pub extern "C" fn kui_host_rect(ptr: *mut KuiCtx, out: *mut KuiLayoutRect) -> bool {
949    guard(false, || {
950        let Some(c) = (unsafe { ctx(ptr) }) else {
951            return false;
952        };
953        let r = c.core().host_rect();
954        write_out(
955            out,
956            KuiLayoutRect {
957                x: r.x,
958                y: r.y,
959                w: r.w,
960                h: r.h,
961                ..Default::default()
962            },
963        )
964    })
965}
966
967/// Seeds the devtools panel's theme override, which its `T` and `A`
968/// chords cycle from: `base` is `"light"`, `"dark"` or empty for the
969/// app's own; `accent` a `0xRRGGBBAA` colour, or 0 for none. False for
970/// any other base word.
971#[unsafe(no_mangle)]
972pub extern "C" fn kui_set_devtools_theme(ptr: *mut KuiCtx, base: KuiStr, accent: u32) -> bool {
973    guard(false, || {
974        let Some(c) = (unsafe { ctx(ptr) }) else {
975            return false;
976        };
977        let base = match kstr(base).as_ref() {
978            "" => None,
979            "light" => Some(kui_core::Appearance::Light),
980            "dark" => Some(kui_core::Appearance::Dark),
981            _ => return false,
982        };
983        let accent = (accent != 0).then(|| color_of(accent));
984        c.core().set_devtools_theme(base, accent);
985        true
986    })
987}
988
989/// Respells the chord that moves the keyboard into the devtools panel and
990/// back out (and brings a hidden panel back) from its default
991/// `"ctrl+shift+i"`: `"f12"`, `"mod+shift+d"` (`mod` is Command on macOS,
992/// Control elsewhere), any spelling a [`KuiMenuItem`]'s `accel` takes. The
993/// panel's other chords stay `Ctrl+Shift+<letter>`. False for a spelling
994/// kui cannot parse, which leaves the chord as it was.
995#[unsafe(no_mangle)]
996pub extern "C" fn kui_set_devtools_key(ptr: *mut KuiCtx, key: KuiStr) -> bool {
997    guard(false, || {
998        let Some(c) = (unsafe { ctx(ptr) }) else {
999            return false;
1000        };
1001        let Some(accel) = kui_core::Accel::parse(&kstr(key)) else {
1002            return false;
1003        };
1004        c.core().set_devtools_key(accel);
1005        true
1006    })
1007}
1008
1009/// The chord [`kui_set_devtools_key`] set, or the default, in its portable
1010/// spelling (`"ctrl+shift+i"`, `"f12"`, `"super+alt+d"`); borrowed until
1011/// the next call. False on a bad context.
1012#[unsafe(no_mangle)]
1013pub extern "C" fn kui_devtools_key(ptr: *mut KuiCtx, out: *mut KuiStr) -> bool {
1014    guard(false, || {
1015        let Some(c) = (unsafe { ctx(ptr) }) else {
1016            return false;
1017        };
1018        c.devtools_key = c.core().devtools_key().spelling();
1019        if let Some(out) = unsafe { out.as_mut() } {
1020            *out = KuiStr {
1021                ptr: c.devtools_key.as_ptr(),
1022                len: c.devtools_key.len(),
1023            };
1024        }
1025        true
1026    })
1027}
1028
1029/// Declares a devtools tab an extension fills: `name` is the tab's
1030/// identity, `label` what the strip shows, `slot` the full
1031/// `namespace/slot` the extension names. While the tab is on show the
1032/// panel declares that slot in the tab's body; otherwise the extension is
1033/// not asked, and no `unknown-slot` is raised. Call it every frame, panel
1034/// on or off. False for a name already declared this frame
1035/// (`duplicate-tab`) or outside a frame.
1036#[unsafe(no_mangle)]
1037pub extern "C" fn kui_devtools_tab(
1038    ptr: *mut KuiCtx,
1039    name: KuiStr,
1040    label: KuiStr,
1041    slot: KuiStr,
1042) -> bool {
1043    guard(false, || {
1044        let Some(c) = (unsafe { ctx(ptr) }) else {
1045            return false;
1046        };
1047        c.core()
1048            .devtools_tab_declare(&kstr(name), &kstr(label), Some(&kstr(slot)))
1049    })
1050}
1051
1052/// Declares a devtools tab the host draws itself, and opens its content
1053/// node only while the tab is on show: true means the node is open, so
1054/// build inside it and [`kui_close`]; false means the tab was declared and
1055/// nothing opened, so skip the body and do not close. What the host builds
1056/// keeps its own keys and events, painted as a layer over the panel's tab
1057/// body and clipped to it:
1058/// `if (kui_devtools_tab_open(ctx, KUI_STR("syntax"), KUI_STR("Tree-sitter"))) { ...; kui_close(ctx); }`.
1059#[unsafe(no_mangle)]
1060pub extern "C" fn kui_devtools_tab_open(ptr: *mut KuiCtx, name: KuiStr, label: KuiStr) -> bool {
1061    guard(false, || {
1062        let Some(c) = (unsafe { ctx(ptr) }) else {
1063            return false;
1064        };
1065        let name = kstr(name);
1066        let core = c.core();
1067        if !core.devtools_tab_declare(&name, &kstr(label), None) || !core.devtools_tab_shown(&name)
1068        {
1069            return false;
1070        }
1071        core.devtools_tab_open(&name);
1072        true
1073    })
1074}
1075
1076/// The node the devtools tree tab has selected, as a key, or 0 for none:
1077/// what an inspector in a declared tab reads.
1078#[unsafe(no_mangle)]
1079pub extern "C" fn kui_devtools_selected(ptr: *mut KuiCtx) -> u64 {
1080    guard(0, || {
1081        unsafe { ctx(ptr) }.map_or(0, |c| c.core().devtools_selected().map_or(0, |k| k.0))
1082    })
1083}
1084
1085/// The tree row under the pointer, as a key, or 0.
1086#[unsafe(no_mangle)]
1087pub extern "C" fn kui_devtools_hovered(ptr: *mut KuiCtx) -> u64 {
1088    guard(0, || {
1089        unsafe { ctx(ptr) }.map_or(0, |c| c.core().devtools_hovered().map_or(0, |k| k.0))
1090    })
1091}
1092
1093/// The node the picker is over while picking, as a key, or 0.
1094#[unsafe(no_mangle)]
1095pub extern "C" fn kui_devtools_picked(ptr: *mut KuiCtx) -> u64 {
1096    guard(0, || {
1097        unsafe { ctx(ptr) }.map_or(0, |c| c.core().devtools_picked().map_or(0, |k| k.0))
1098    })
1099}
1100
1101/// Raises the devtools picker from outside the panel (an inspector asking
1102/// "which node?") or puts it away. While it is up the node under the
1103/// pointer is [`kui_devtools_picked`], and a press lands it in
1104/// [`kui_devtools_selected`]. Raised while a declared tab is on show the
1105/// pick leaves that tab up; otherwise it shows the tree tab. A hidden
1106/// panel comes back docked.
1107#[unsafe(no_mangle)]
1108pub extern "C" fn kui_set_devtools_pick(ptr: *mut KuiCtx, on: bool) {
1109    guard((), || {
1110        if let Some(c) = unsafe { ctx(ptr) } {
1111            c.core().set_devtools_pick(on);
1112        }
1113    });
1114}
1115
1116/// Whether the panel's picker is up.
1117#[unsafe(no_mangle)]
1118pub extern "C" fn kui_devtools_picking(ptr: *mut KuiCtx) -> bool {
1119    guard(false, || {
1120        unsafe { ctx(ptr) }.is_some_and(|c| c.core().devtools_picking())
1121    })
1122}
1123
1124/// Shows the devtools tab named `name`: one of the panel's own (`facts`,
1125/// `events`, `tree`) or a declared tab's. A declared name the panel does
1126/// not list yet is kept and shows once a frame declares it; the return
1127/// says whether the panel lists it now (false on a bad context too). A
1128/// hidden panel comes back docked; [`kui_set_devtools`] is still the
1129/// host's to call. Call it once, not every frame, or it pins the strip
1130/// against the user's own clicks.
1131#[unsafe(no_mangle)]
1132pub extern "C" fn kui_set_devtools_tab(ptr: *mut KuiCtx, name: KuiStr) -> bool {
1133    guard(false, || {
1134        unsafe { ctx(ptr) }.is_some_and(|c| c.core().set_devtools_tab(&kstr(name)))
1135    })
1136}
1137
1138/// The devtools tab currently selected, by name, panel on or off;
1139/// borrowed until the next call. False on a bad context.
1140#[unsafe(no_mangle)]
1141pub extern "C" fn kui_devtools_current_tab(ptr: *mut KuiCtx, out: *mut KuiStr) -> bool {
1142    guard(false, || {
1143        let Some(c) = (unsafe { ctx(ptr) }) else {
1144            return false;
1145        };
1146        c.devtools_tab = c.core().devtools_current_tab();
1147        if let Some(out) = unsafe { out.as_mut() } {
1148            *out = KuiStr {
1149                ptr: c.devtools_tab.as_ptr(),
1150                len: c.devtools_tab.len(),
1151            };
1152        }
1153        true
1154    })
1155}
1156
1157/// Selects a node in the devtools tree tab and reveals it there, as the
1158/// picker does; 0 clears.
1159#[unsafe(no_mangle)]
1160pub extern "C" fn kui_set_devtools_selected(ptr: *mut KuiCtx, key: u64) {
1161    guard((), || {
1162        if let Some(c) = unsafe { ctx(ptr) } {
1163            c.core()
1164                .set_devtools_selected((key != 0).then_some(kui_core::Key(key)));
1165        }
1166    });
1167}
1168
1169/// The key legend the devtools facts tab shows: `count` pairs, the keys
1170/// in `keys` and what each does in `what`, index for index.
1171#[unsafe(no_mangle)]
1172pub extern "C" fn kui_set_devtools_legend(
1173    ptr: *mut KuiCtx,
1174    keys: *const KuiStr,
1175    what: *const KuiStr,
1176    count: usize,
1177) {
1178    guard((), || {
1179        let Some(c) = (unsafe { ctx(ptr) }) else {
1180            return;
1181        };
1182        let rows: Vec<(String, String)> = if keys.is_null() || what.is_null() {
1183            Vec::new()
1184        } else {
1185            let keys = unsafe { std::slice::from_raw_parts(keys, count) };
1186            let what = unsafe { std::slice::from_raw_parts(what, count) };
1187            keys.iter()
1188                .zip(what)
1189                .map(|(k, w)| (kstr(*k).into_owned(), kstr(*w).into_owned()))
1190                .collect()
1191        };
1192        let borrowed: Vec<(&str, &str)> =
1193            rows.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect();
1194        c.core().set_devtools_legend(&borrowed);
1195    });
1196}
1197
1198/// Turns the per-frame node snapshot behind [`kui_nodes`] on or off. Off
1199/// by default: the copy costs a pass over every node each frame.
1200#[unsafe(no_mangle)]
1201pub extern "C" fn kui_set_inspect(ptr: *mut KuiCtx, on: bool) {
1202    guard((), || {
1203        if let Some(c) = unsafe { ctx(ptr) } {
1204            c.core().set_inspect(on);
1205        }
1206    });
1207}
1208
1209/// The last finished frame's nodes in tree order, as a list of maps, each
1210/// with `key`, `parent`, `depth`, `kind`, `label`, `rect`, `role`,
1211/// `text`, `flags`, `layer`, `origin`, `children`, the layout spec and
1212/// `events` (the node's payloads by handler name). Read it with
1213/// [`kui_value_at`] and [`kui_value_get`]. Empty until
1214/// `kui_set_inspect(ctx, true)` and a frame after it. Borrowed until the
1215/// next call; NULL on a bad context.
1216#[unsafe(no_mangle)]
1217pub extern "C" fn kui_nodes(ptr: *mut KuiCtx) -> *const KuiValue {
1218    guard(std::ptr::null(), || {
1219        let Some(c) = (unsafe { ctx(ptr) }) else {
1220            return std::ptr::null();
1221        };
1222        let list = c
1223            .core()
1224            .nodes()
1225            .iter()
1226            .map(|n| {
1227                let mut v = n.to_value(kui_core::Handles::INT);
1228                if let Value::Map(entries) = &mut v {
1229                    entries.push((
1230                        "events".into(),
1231                        Value::Map(
1232                            n.events
1233                                .iter()
1234                                .map(|(name, v)| ((*name).to_string(), v.clone()))
1235                                .collect(),
1236                        ),
1237                    ));
1238                }
1239                v
1240            })
1241            .collect();
1242        c.nodes = Some(Box::new(KuiValue(Value::List(list))));
1243        c.nodes
1244            .as_deref()
1245            .map_or(std::ptr::null(), |v| v as *const KuiValue)
1246    })
1247}
1248
1249// ---------------------------------------------------------------------------
1250// Parity with the shared prop schema. `KuiSpec` has to be a static repr(C)
1251// layout, so it cannot read `kui_core::schema::PROPS` at runtime the way Lua
1252// and Node do — instead these tests pin it to the table: a schema row with
1253// no C counterpart fails `every_schema_prop_has_a_c_counterpart`.
1254
1255#[cfg(test)]
1256mod schema_parity;
1257
1258#[cfg(test)]
1259mod tests;
1260
1261// ---------------------------------------------------------------------------
1262// ABI parity with include/kui.h. The header is hand-written (it carries prose
1263// the generator would lose), so nothing in Rust makes it match the structs
1264// in `types`: a field added to `KuiSpec` but missing from - or misordered in - the
1265// header silently shifts every field after it at runtime. This module writes
1266// `target/kui-abi-assert.c`, a translation unit of `_Static_assert`s pinning
1267// each field's offset, size and C type to what Rust actually lays out, and
1268// each member of the enums the API reads as list indices to its position in
1269// the list; the `cbuild` tool compiles it against the header, in CI too.
1270
1271/// The two halves of the ABI handshake: a version a host can compare, and
1272/// a size on every struct the library writes into the host's memory.
1273#[cfg(test)]
1274mod abi_handshake;
1275
1276#[cfg(test)]
1277mod abi_parity;