Skip to main content

pane/
api.rs

1//! # Pane
2//!
3//! A RON-driven retained UI library for wgpu applications.
4//!
5//! Layouts are defined in `.ron` files and hot-reloaded at runtime. Pane handles
6//! all rendering, input, animation, and state — your app just reads actions and
7//! drives values.
8//!
9//! ## Three usage modes
10//!
11//! | Mode | Use when |
12//! |------|----------|
13//! | [`run`] | Pane owns the window and event loop entirely |
14//! | [`overlay`] / [`PaneOverlay`] | You own wgpu; Pane renders into your frame |
15//! | [`headless`] / [`PaneHeadless`] | No GPU — logic, state, and events only (tests, servers) |
16//!
17//! ## Quick start (overlay)
18//!
19//! ```ignore
20//! let mut ui = pane::overlay("ui/main.ron", &device, &queue, surface_format, None);
21//!
22//! // in your event loop:
23//! ui.handle_event(&window_event, width as f32, height as f32);
24//!
25//! // in your render pass:
26//! for action in ui.draw(&mut encoder, &view, width as f32, height as f32) {
27//!     match action {
28//!         PaneAction::Custom(id) => println!("button pressed: {id}"),
29//!         PaneAction::Slider(id, val) => println!("{id} = {val}"),
30//!         _ => {}
31//!     }
32//! }
33//! ```
34
35use std::collections::HashMap;
36use std::sync::{Arc, mpsc};
37use std::time::Instant;
38use winit::event::WindowEvent;
39
40use crate::builder::{StyleMap, build_logic};
41use crate::draw::{Pane, Scene, ShaderId};
42use crate::input::Input;
43use crate::loader::{MenuDef, UiMsg, load_menu, load_menu_soft, spawn_watcher};
44use crate::logic::Root;
45use crate::styles::StyleRegistry;
46use crate::textures::{GifMode, TextureId, TextureRegistry};
47
48// ── Shared Helpers ────────────────────────────────────────────────────────────
49
50/// An event emitted by the UI in response to user interaction.
51///
52/// Returned as a `Vec<PaneAction>` from [`PaneOverlay::draw`] and [`PaneHeadless::update`]
53/// each frame. Process them in a `match` to drive your application logic.
54///
55/// Widget IDs in the variants correspond to the `id` field set in your `.ron` file.
56#[derive(Debug, Clone, PartialEq)]
57pub enum PaneAction {
58    /// The active root has been switched, either by a button action or a [`PaneOverlay::draw`]
59    /// call that processed a `SwitchRoot` message. Contains the name of the new root.
60    SwitchRoot(String),
61
62    /// A button with a `Quit` action was pressed, or the window close button was clicked.
63    /// In standalone mode this exits immediately; in overlay/headless mode you should
64    /// use this to exit your own event loop.
65    Quit,
66
67    /// A button with a `Custom(tag)` action was pressed. The `String` is the tag from
68    /// the RON definition.
69    Custom(String),
70
71    /// A slider's value changed. `f32` is the new value in the slider's `min..=max` range.
72    Slider(String, f32),
73
74    /// A text box's content changed (fires on every keystroke). Contains the widget id
75    /// and the full current string.
76    TextChanged(String, String),
77
78    /// The user pressed Enter in a text box. Contains the widget id and the submitted string.
79    TextSubmitted(String, String),
80
81    /// A toggle was flipped. `bool` is the new checked state.
82    Toggle(String, bool),
83
84    /// A dropdown selection changed. Contains the widget id, the new selected index,
85    /// and the selected option string.
86    Dropdown(String, usize, String),
87
88    /// A radio group selection changed. Contains the widget id, the new selected index,
89    /// and the selected option string.
90    Radio(String, usize, String),
91}
92
93// ── WriteValue ────────────────────────────────────────────────────────────────
94
95/// The value to set when calling [`PaneOverlay::write`] or [`PaneHeadless::write`].
96///
97/// Each variant corresponds to a writable widget type. Passing the wrong variant
98/// for a given widget id is a no-op (a warning is printed).
99///
100/// # Examples
101///
102/// ```ignore
103/// ui.write("volume", WriteValue::Slider(0.8));
104/// ui.write("mute",   WriteValue::Toggle(true));
105/// ui.write("name",   WriteValue::Text("Player 1".into()));
106/// ui.write("lang",   WriteValue::Selected(2));
107/// ```
108#[derive(Debug, Clone)]
109pub enum WriteValue {
110    /// Set a `Slider` value. Clamped to the slider's `min..=max` range.
111    Slider(f32),
112    /// Set a `Toggle` checked state.
113    Toggle(bool),
114    /// Set a `TextBox` string. Truncated to `max_len` if set.
115    Text(String),
116    /// Set the selected index of a `Dropdown` or `RadioGroup`. Clamped to the valid range.
117    Selected(usize),
118}
119
120// ── Private helpers ───────────────────────────────────────────────────────────
121
122/// Register all built-in and user-defined shaders into the renderer's shader table.
123///
124/// Returns `(name → ShaderId map, id of the built-in "textured" shader)`.
125/// The textured shader id is extracted separately because it is used by both the
126/// background quad and texture-overlay draw calls.
127fn register_shaders(
128    renderer: &mut Pane,
129    device: &wgpu::Device,
130    shader_dirs: &[String],
131    tex_reg: &TextureRegistry,
132) -> (HashMap<String, ShaderId>, ShaderId) {
133    let mut map = HashMap::new();
134    for name in crate::shader_reg::BUILTIN_SHADER_NAMES {
135        let id = renderer.register_shader(device, &crate::shader_reg::load_shader(name), tex_reg);
136        map.insert(name.to_string(), id);
137    }
138    for dir in shader_dirs {
139        for (name, src) in crate::shader_reg::scan_shader_dir(dir) {
140            map.insert(name, renderer.register_shader(device, &src, tex_reg));
141        }
142    }
143    let textured_id = *map
144        .get("textured")
145        .expect("[pane_ui] built-in 'textured' shader missing");
146    (map, textured_id)
147}
148
149/// Load a texture (or GIF) by path, returning a cached id if the same path was
150/// already loaded this session.
151///
152/// Errors are printed to stderr and return `None` so callers can gracefully skip
153/// a missing asset without panicking.
154fn load_texture_cached(
155    tex_reg: &mut TextureRegistry,
156    device: &wgpu::Device,
157    queue: &wgpu::Queue,
158    tex_cache: &mut HashMap<String, TextureId>,
159    path: &str,
160    gif_mode: Option<GifMode>,
161) -> Option<TextureId> {
162    if let Some(&id) = tex_cache.get(path) {
163        return Some(id);
164    }
165    let result = match gif_mode {
166        Some(mode) => tex_reg.load_gif(device, queue, path, mode),
167        None => tex_reg.load(device, queue, path),
168    };
169    match result {
170        Ok(id) => {
171            tex_cache.insert(path.to_string(), id);
172            Some(id)
173        }
174        Err(e) => {
175            eprintln!("[pane_ui] texture load error \\'{path}\\': {e}");
176            None
177        }
178    }
179}
180
181/// Register shaders, load styles, and build the widget tree from a parsed `MenuDef`.
182///
183/// Called both at startup and on hot reload. Returns all the pieces that need to
184/// be swapped into `Persistent` atomically when the UI rebuilds.
185pub(crate) fn init_and_build(
186    renderer: &mut Pane,
187    device: &wgpu::Device,
188    queue: &wgpu::Queue,
189    menu: &MenuDef,
190    _tx: &mpsc::Sender<UiMsg>,
191    tex_reg: &mut TextureRegistry,
192) -> (
193    StyleRegistry,
194    HashMap<String, Root>,
195    Option<String>,
196    ShaderId,
197    Option<crate::styles::StyleId>,
198    StyleMap,
199) {
200    let (shader_map, textured_id) = register_shaders(renderer, device, &menu.shader_dirs, tex_reg);
201    let mut tex_cache: HashMap<String, TextureId> = HashMap::new();
202    let mut style_registry = StyleRegistry::new();
203
204    let style_map = crate::styles_reg::register_all(
205        &mut style_registry,
206        &shader_map,
207        &menu.style_dirs,
208        textured_id,
209        &mut |path, gif_mode| {
210            load_texture_cached(tex_reg, device, queue, &mut tex_cache, path, gif_mode)
211        },
212    );
213
214    let default_style = menu.default_style.as_deref().and_then(|name| {
215        if let Some(&(id, _)) = style_map.get(name) {
216            Some(id)
217        } else {
218            eprintln!("[pane_ui] default_style '{name}' not found in style map, ignoring");
219            None
220        }
221    });
222
223    let (roots, active_root) = build_logic(menu, &style_map, textured_id, &mut |path, gif_mode| {
224        load_texture_cached(tex_reg, device, queue, &mut tex_cache, path, gif_mode)
225    });
226
227    (
228        style_registry,
229        roots,
230        active_root,
231        textured_id,
232        default_style,
233        style_map,
234    )
235}
236
237/// Convert an internal [`UiMsg`] to the public [`PaneAction`] type.
238///
239/// Navigation messages (`SwitchRoot`, `Reload`, `SwitchTabPage`) are handled
240/// upstream before this is called and return `None` here.
241pub(crate) fn msg_to_action(msg: UiMsg) -> Option<PaneAction> {
242    match msg {
243        UiMsg::Quit => Some(PaneAction::Quit),
244        UiMsg::Custom(s) => Some(PaneAction::Custom(s)),
245        UiMsg::Slider(id, val) => Some(PaneAction::Slider(id, val)),
246        UiMsg::TextChanged(id, s) => Some(PaneAction::TextChanged(id, s)),
247        UiMsg::TextSubmitted(id, s) => Some(PaneAction::TextSubmitted(id, s)),
248        UiMsg::Toggle(tag, checked) => Some(PaneAction::Toggle(tag, checked)),
249        UiMsg::Dropdown(tag, idx, val) => Some(PaneAction::Dropdown(tag, idx, val)),
250        UiMsg::Radio(tag, idx, val) => Some(PaneAction::Radio(tag, idx, val)),
251        _ => None,
252    }
253}
254
255/// Build an initial [`Persistent`] for standalone mode.
256///
257/// Called on the first frame tick, once the wgpu device and queue are available.
258/// Standalone mode does not store the renderer in `Persistent` (it stays in `Pane`
259/// which the draw closure owns), so `renderer` is always `None` here.
260///
261/// The `rx` field is filled with a dummy channel receiver. Standalone mode drains
262/// messages from its own outer `rx` via `drain_messages_standalone` before
263/// `run_frame` is called, so `check_messages` inside `run_frame` only ever sees an
264/// empty channel. This avoids having to restructure the standalone harness around
265/// the same receiver.
266fn init_persistent(
267    pane: &mut Pane,
268    menu: &MenuDef,
269    tx: &mpsc::Sender<UiMsg>,
270    path_owned: &str,
271    bg_path: Option<&String>,
272    clear_color: Option<crate::draw::Color>,
273) -> crate::threader::Persistent {
274    let device = pane
275        .device()
276        .expect("standalone: device not yet initialized")
277        .clone();
278    let queue = pane
279        .queue()
280        .expect("standalone: queue not yet initialized")
281        .clone();
282    let mut tex_reg = TextureRegistry::new(&device, &queue);
283    let (new_styles, new_roots, new_active, textured_id, new_default_style, new_style_map) =
284        init_and_build(pane, &device, &queue, menu, tx, &mut tex_reg);
285    let background = bg_path.and_then(|p| {
286        let result = if p.to_lowercase().ends_with(".gif") {
287            tex_reg.load_gif(&device, &queue, p, GifMode::Loop)
288        } else {
289            tex_reg.load(&device, &queue, p)
290        };
291        result.ok().map(|id| {
292            let (width, height) = tex_reg.dimensions(id);
293            crate::threader::BackgroundDef {
294                shader: textured_id,
295                texture: id,
296                width,
297                height,
298            }
299        })
300    });
301    crate::threader::Persistent {
302        roots: new_roots,
303        active_root: new_active,
304        tab_pages: std::collections::HashMap::new(),
305        nav: crate::logic::NavState::default(),
306        styles: new_styles,
307        style_map: new_style_map,
308        default_style: new_default_style,
309        tex_reg: Some(tex_reg),
310        device: Some(device),
311        queue: Some(queue),
312        renderer: None,
313        background,
314        clear_color,
315        pending_actions: Vec::new(),
316        tx: tx.clone(),
317        rx: mpsc::channel().1,
318        ron_path: path_owned.to_string(),
319        debug: std::env::var("PANE_DEBUG").is_ok(),
320        headless_accessible: false,
321        osk: crate::keyboard::OskState::default(),
322    }
323}
324
325/// Drain the standalone message channel for one frame.
326///
327/// Handles `Reload` (which needs a live `&mut Pane` unavailable inside
328/// `check_messages`), `SwitchRoot`, `SwitchTabPage`, and `Quit` directly.
329/// Everything else is forwarded through [`msg_to_action`] into `pending_actions`.
330///
331/// Must be called before `run_frame` so that `check_messages` sees an empty
332/// channel on its drain pass.
333fn drain_messages_standalone(
334    rx: &mpsc::Receiver<UiMsg>,
335    p: &mut crate::threader::Persistent,
336    pane: &mut Pane,
337    path_owned: &str,
338    tx: &mpsc::Sender<UiMsg>,
339) {
340    while let Ok(msg) = rx.try_recv() {
341        match msg {
342            UiMsg::SwitchRoot(name) => {
343                p.active_root = Some(name.clone());
344                p.nav = crate::logic::NavState::default();
345                p.pending_actions.push(PaneAction::SwitchRoot(name));
346            }
347            UiMsg::SwitchTabPage { tab_id, page } => {
348                p.tab_pages.insert(tab_id, page);
349            }
350            UiMsg::Quit => std::process::exit(0),
351            UiMsg::Toast {
352                message,
353                duration,
354                x,
355                y,
356                width,
357                height,
358            } => {
359                push_toast_into(p, message, duration, x, y, width, height);
360            }
361            UiMsg::Reload => {
362                if let Ok(new_menu) = load_menu_soft(path_owned) {
363                    let device = p.device.as_ref().unwrap().clone();
364                    let queue = p.queue.as_ref().unwrap().clone();
365                    let prev_active = p.active_root.clone();
366                    let tex_reg = p.tex_reg.as_mut().unwrap();
367                    let (
368                        new_styles,
369                        new_roots,
370                        new_active,
371                        _textured_id,
372                        new_default_style,
373                        new_style_map,
374                    ) = init_and_build(pane, &device, &queue, &new_menu, tx, tex_reg);
375                    p.styles = new_styles;
376                    p.style_map = new_style_map;
377                    p.active_root = prev_active
378                        .filter(|name| new_roots.iter().any(|(k, _)| k == name))
379                        .or(new_active);
380                    p.roots = new_roots;
381                    p.default_style = new_default_style;
382                    p.nav = crate::logic::NavState::default();
383                    p.clear_color = new_menu.clear_color;
384                }
385            }
386            other => {
387                if let Some(a) = msg_to_action(other) {
388                    p.pending_actions.push(a);
389                }
390            }
391        }
392    }
393}
394
395// ── Mode 1: Standalone ────────────────────────────────────────────────────────
396
397/// Run Pane as a standalone application, owning the window and event loop.
398///
399/// This is the simplest integration — Pane creates the window, runs the loop,
400/// and never returns. Use [`overlay`] or [`headless`] if you need to retain
401/// control of the event loop or the wgpu device.
402///
403/// The `.ron` file path is relative to the working directory. Hot reload is
404/// enabled automatically if `hot_reload: true` is set in the root definition.
405///
406/// # Panics
407///
408/// Panics if the `.ron` file cannot be read or parsed.
409pub fn run(path: &str) {
410    run_with(path, |_, _| {});
411}
412
413// ── StandaloneHandle ──────────────────────────────────────────────────────────
414
415/// A handle passed to the [`run_with`] callback each frame, allowing you to
416/// call toast notifications and other runtime APIs from standalone mode.
417pub struct StandaloneHandle<'a> {
418    persistent: &'a mut crate::threader::Persistent,
419}
420
421impl StandaloneHandle<'_> {
422    // ── Read / Write ──────────────────────────────────────────────────────────
423
424    /// Returns the item data and its current visual state for any widget by id.
425    ///
426    /// See [`PaneOverlay::read`] for details.
427    #[must_use]
428    pub fn read(&self, id: &str) -> Option<(&crate::items::UiItem, crate::widgets::WidgetState)> {
429        read_into(self.persistent, id)
430    }
431
432    /// Set a widget's value programmatically.
433    ///
434    /// See [`WriteValue`] for the supported widget types and their matching variants.
435    pub fn write(&mut self, id: &str, value: &WriteValue) {
436        write_into(self.persistent, id, value);
437    }
438
439    // ── Create / Destroy ──────────────────────────────────────────────────────
440
441    /// Add a code-built widget to a named root. Returns `false` if the id is already
442    /// in use or the root doesn't exist.
443    pub fn create(&mut self, root: &str, builder: impl crate::build::WidgetBuilder) -> bool {
444        create_into(self.persistent, root, builder)
445    }
446
447    /// Remove a widget by id from any root. Returns `true` if a widget was removed.
448    pub fn destroy(&mut self, id: &str) -> bool {
449        destroy_into(self.persistent, id)
450    }
451
452    /// Create an empty root that can be populated with [`Self::create`].
453    pub fn create_root(&mut self, name: impl Into<String>) {
454        create_root_into(self.persistent, name);
455    }
456
457    /// Resolve a style name to its `StyleId` for use with builders.
458    #[must_use]
459    pub fn style_id(&self, name: &str) -> Option<crate::styles::StyleId> {
460        style_id_in(self.persistent, name)
461    }
462
463    /// Returns the name of the current default style, if one is set.
464    #[must_use]
465    pub fn default_style(&self) -> Option<&str> {
466        default_style_in(self.persistent)
467    }
468
469    /// Switch the default style by name. Returns `false` if the name is not registered.
470    pub fn set_default_style(&mut self, name: &str) -> bool {
471        set_default_style_in(self.persistent, name)
472    }
473
474    // ── Toast ─────────────────────────────────────────────────────────────────
475
476    /// Spawn a transient toast notification at the given position.
477    pub fn push_toast(
478        &mut self,
479        message: impl Into<String>,
480        duration: f32,
481        x: f32,
482        y: f32,
483        width: f32,
484        height: f32,
485    ) {
486        push_toast_into(
487            self.persistent,
488            message.into(),
489            duration,
490            x,
491            y,
492            width,
493            height,
494        );
495    }
496}
497
498/// Like [`run`], but gives you a [`StandaloneHandle`] each frame to call write, toast, etc.
499///
500/// `on_action` is called once per [`PaneAction`] emitted that frame.
501///
502/// ```no_run
503/// pane::run_with("assets/menu.ron", |ui, action| {
504///     if let pane::PaneAction::Custom(ref id) = action {
505///         if id == "save" { ui.push_toast("Saved!", 2.0, 0.0, -400.0, 300.0, 60.0); }
506///     }
507/// });
508/// ```
509///
510/// # Panics
511///
512/// Panics if the `.ron` file cannot be read or parsed, or if the standalone
513/// wgpu device/queue are not yet initialized when the first frame fires.
514pub fn run_with<F>(path: &str, mut on_action: F)
515where
516    F: FnMut(&mut StandaloneHandle, PaneAction) + 'static,
517{
518    let path_owned = path.to_string();
519    let menu = load_menu(&path_owned);
520
521    let (tx, rx) = mpsc::channel::<UiMsg>();
522    if menu.hot_reload {
523        spawn_watcher(
524            path_owned.clone(),
525            menu.shader_dirs.clone(),
526            menu.style_dirs.clone(),
527            tx.clone(),
528        );
529    }
530
531    let bg_path = menu.background.clone();
532    let clear_color = menu.clear_color;
533    let mut persistent: Option<crate::threader::Persistent> = None;
534    let mut last_time = Instant::now();
535
536    crate::draw::run(
537        move |pane: &mut Pane, scene: &mut Scene, input: &mut Input, pw: f32, ph: f32, _dt: f32| {
538            // First tick: initialise once the device/queue are available.
539            if persistent.is_none() {
540                persistent = Some(init_persistent(
541                    pane,
542                    &menu,
543                    &tx,
544                    &path_owned,
545                    bg_path.as_ref(),
546                    clear_color,
547                ));
548            }
549
550            let p = persistent.as_mut().unwrap();
551
552            // Drain messages (including Reload) before run_frame sees the channel.
553            drain_messages_standalone(&rx, p, pane, &path_owned, &tx);
554
555            let mut frame = crate::threader::Frame {
556                dt: 0.0,
557                last_tick: last_time,
558                pw,
559                ph,
560                input: std::mem::take(input),
561                scene: std::mem::take(scene),
562            };
563            {
564                let mut ctx = crate::threader::FrameCtx {
565                    persistent: p,
566                    frame: &mut frame,
567                    standalone_pane: Some(pane),
568                };
569                crate::order::run_frame(&mut ctx, None, None);
570            }
571            last_time = frame.last_tick;
572            *input = frame.input;
573            *scene = frame.scene;
574
575            let actions = std::mem::take(&mut p.pending_actions);
576            let cc = p.clear_color;
577            let cursor = [
578                input.mouse_x,
579                input.mouse_y,
580                f32::from(u8::from(input.left_pressed)),
581            ];
582            let mut handle = StandaloneHandle { persistent: p };
583            for action in actions {
584                on_action(&mut handle, action);
585            }
586
587            pane.present_standalone(
588                scene,
589                pw,
590                ph,
591                frame.dt,
592                cursor,
593                cc,
594                p.tex_reg.as_mut().unwrap(),
595            );
596            scene.clear();
597        },
598    );
599}
600
601// ── Mode 2: Overlay ───────────────────────────────────────────────────────────
602
603/// A Pane UI instance that renders into an existing wgpu surface.
604///
605/// You retain full control of the window, event loop, and wgpu device. Pane
606/// composites its output on top of whatever you have already rendered that frame.
607///
608/// Create with [`overlay`], then call [`handle_event`](PaneOverlay::handle_event)
609/// from your winit event loop and [`draw`](PaneOverlay::draw) each frame.
610pub struct PaneOverlay {
611    persistent: crate::threader::Persistent,
612    input: Input,
613    scene: Scene,
614    last_time: Instant,
615    gilrs: Option<gilrs::Gilrs>,
616}
617
618/// Create a [`PaneOverlay`] that renders into your existing wgpu surface.
619///
620/// - `path` — path to the root `.ron` layout file, relative to the working directory.
621/// - `device` / `queue` — your wgpu device and queue. Pane stores `Arc` clones of
622///   these for hot-reload and per-frame rendering.
623/// - `format` — the texture format of your swap chain surface.
624/// - `gilrs` — optional pre-constructed gilrs instance for gamepad input.
625///
626/// # Panics
627///
628/// Panics if the `.ron` file cannot be read or parsed, or if the built-in `textured`
629/// shader is missing from the shader registry.
630#[must_use]
631pub fn overlay(
632    path: &str,
633    device: &wgpu::Device,
634    queue: &wgpu::Queue,
635    format: wgpu::TextureFormat,
636    gilrs: Option<gilrs::Gilrs>,
637) -> PaneOverlay {
638    let menu = load_menu(path);
639    let (tx, rx) = mpsc::channel::<UiMsg>();
640    let mut renderer = Pane::new(device, queue, format);
641    let mut tex_reg = TextureRegistry::new(device, queue);
642    let (new_styles, new_roots, new_active, _textured_id, new_default_style, new_style_map) =
643        init_and_build(&mut renderer, device, queue, &menu, &tx, &mut tex_reg);
644
645    if menu.hot_reload {
646        spawn_watcher(
647            path.to_string(),
648            menu.shader_dirs.clone(),
649            menu.style_dirs.clone(),
650            tx.clone(),
651        );
652    }
653
654    // wgpu Device and Queue are internally ref-counted; clone here is cheap.
655    let device_arc = Arc::new(device.clone());
656    let queue_arc = Arc::new(queue.clone());
657
658    let persistent = crate::threader::Persistent {
659        roots: new_roots,
660        active_root: new_active,
661        tab_pages: std::collections::HashMap::new(),
662        nav: crate::logic::NavState::default(),
663        styles: new_styles,
664        style_map: new_style_map,
665        default_style: new_default_style,
666        tex_reg: Some(tex_reg),
667        device: Some(device_arc),
668        queue: Some(queue_arc),
669        renderer: Some(renderer),
670        background: None,
671        clear_color: None,
672        pending_actions: Vec::new(),
673        tx,
674        rx,
675        ron_path: path.to_string(),
676        debug: false,
677        headless_accessible: false,
678        osk: crate::keyboard::OskState::default(),
679    };
680
681    PaneOverlay {
682        persistent,
683        input: Input::new(),
684        scene: Scene::new(),
685        last_time: Instant::now(),
686        gilrs: gilrs.or_else(|| gilrs::Gilrs::new().ok()),
687    }
688}
689
690// ── Shared helpers (Overlay + Headless) ───────────────────────────────────────
691
692/// Return a mutable reference to the currently active root, if any.
693fn active_root_mut(
694    persistent: &mut crate::threader::Persistent,
695) -> Option<&mut crate::logic::Root> {
696    persistent
697        .active_root
698        .as_deref()
699        .and_then(|n| persistent.roots.get_mut(n))
700}
701
702/// Apply a [`WriteValue`] to the widget with the given id in the active root.
703///
704/// Shared by [`PaneOverlay::write`] and [`PaneHeadless::write`].
705/// Resolve a style name to its `StyleId`, if registered.
706fn style_id_in(
707    persistent: &crate::threader::Persistent,
708    name: &str,
709) -> Option<crate::styles::StyleId> {
710    persistent.style_map.get(name).map(|&(id, _)| id)
711}
712
713fn default_style_in(persistent: &crate::threader::Persistent) -> Option<&str> {
714    let id = persistent.default_style?;
715    persistent
716        .style_map
717        .iter()
718        .find(|(_, v)| v.0 == id)
719        .map(|(name, _)| name.as_str())
720}
721
722fn set_default_style_in(persistent: &mut crate::threader::Persistent, name: &str) -> bool {
723    match persistent.style_map.get(name).map(|&(id, _)| id) {
724        Some(id) => {
725            persistent.default_style = Some(id);
726            true
727        }
728        None => false,
729    }
730}
731
732/// Add a code-built widget to a named root. Fails if the id already exists or the root is missing.
733fn create_into(
734    persistent: &mut crate::threader::Persistent,
735    root: &str,
736    builder: impl crate::build::WidgetBuilder,
737) -> bool {
738    let (item, state) = builder.build(persistent.default_style);
739    let id = item.id().to_string();
740    for r in persistent.roots.values() {
741        if r.items.iter().any(|(i, _)| i.id() == id) {
742            eprintln!("[pane_ui] create: id '{id}' already exists");
743            return false;
744        }
745    }
746    let Some(r) = persistent.roots.get_mut(root) else {
747        eprintln!("[pane_ui] create: root '{root}' not found");
748        return false;
749    };
750    r.items.push((item, state));
751    true
752}
753
754/// Remove the first widget matching `id` from any root. Returns true if found.
755fn destroy_into(persistent: &mut crate::threader::Persistent, id: &str) -> bool {
756    for r in persistent.roots.values_mut() {
757        if let Some(idx) = r.items.iter().position(|(item, _)| item.id() == id) {
758            r.items.remove(idx);
759            if persistent.nav.focused_id.as_deref() == Some(id) {
760                persistent.nav = crate::logic::NavState::default();
761            }
762            return true;
763        }
764    }
765    false
766}
767
768/// Create a new empty root. No-op if a root with this name already exists.
769fn create_root_into(persistent: &mut crate::threader::Persistent, name: impl Into<String>) {
770    let name = name.into();
771    persistent
772        .roots
773        .entry(name)
774        .or_insert_with(crate::logic::Root::new);
775}
776
777/// Find a widget by id across all roots. Returns the item and a snapshot of its visual state.
778fn read_into<'a>(
779    persistent: &'a crate::threader::Persistent,
780    id: &str,
781) -> Option<(&'a crate::items::UiItem, crate::widgets::WidgetState)> {
782    for root in persistent.roots.values() {
783        for (item, state) in &root.items {
784            if item.id() == id {
785                return Some((item, state.visual()));
786            }
787        }
788    }
789    None
790}
791
792fn write_into(persistent: &mut crate::threader::Persistent, id: &str, value: &WriteValue) {
793    use crate::items::UiItem;
794    use crate::widgets::ItemState;
795    for root in persistent.roots.values_mut() {
796        for (item, state) in &mut root.items {
797            if item.id() != id {
798                continue;
799            }
800            match (item, state, value) {
801                (UiItem::Slider(s), ItemState::Slider(st), WriteValue::Slider(v)) => {
802                    st.value = v.clamp(s.min, s.max);
803                }
804                (UiItem::Toggle(_), ItemState::Toggle(st), WriteValue::Toggle(v)) => {
805                    st.checked = *v;
806                }
807                (UiItem::TextBox(tb), ItemState::TextBox(st), WriteValue::Text(v)) => {
808                    st.text = tb
809                        .max_len
810                        .map_or_else(|| v.clone(), |limit| v.chars().take(limit).collect());
811                    st.cursor_pos = st.text.chars().count();
812                }
813                (UiItem::Dropdown(dd), ItemState::Dropdown(st), WriteValue::Selected(i)) => {
814                    st.selected = (*i).min(dd.items.len().saturating_sub(1));
815                }
816                (UiItem::RadioGroup(rg), ItemState::RadioGroup(st), WriteValue::Selected(i)) => {
817                    st.selected = (*i).min(rg.items.len().saturating_sub(1));
818                }
819                _ => {
820                    eprintln!("[pane_ui] write('{id}'): value type does not match widget type");
821                }
822            }
823            return;
824        }
825    }
826    eprintln!("[pane_ui] write('{id}'): no widget found");
827}
828
829/// Insert a transient toast widget into the active root's item list.
830///
831/// Shared by [`PaneOverlay::push_toast`], [`PaneHeadless::push_toast`], and
832/// [`StandaloneHandle::push_toast`].
833pub(crate) fn push_toast_into(
834    persistent: &mut crate::threader::Persistent,
835    message: String,
836    duration: f32,
837    x: f32,
838    y: f32,
839    width: f32,
840    height: f32,
841) {
842    if let Some(name) = persistent.active_root.as_deref()
843        && let Some(root) = persistent.roots.get_mut(name)
844    {
845        // Use item count as id suffix so two toasts on the same root get distinct ids.
846        let id = format!("__toast_{}", root.items.len());
847        root.items.push((
848            crate::items::UiItem::Toast(crate::items::Toast {
849                id,
850                x,
851                y,
852                width,
853                height,
854                message,
855                duration,
856                shape: persistent.default_style,
857            }),
858            crate::widgets::ItemState::Toast(crate::widgets::ToastState::new(duration)),
859        ));
860    }
861}
862
863/// Delegate for the `actor_move_to` method shared across all three API types.
864fn actor_move_to_into(
865    persistent: &mut crate::threader::Persistent,
866    id: &str,
867    x: f32,
868    y: f32,
869    speed: f32,
870) {
871    if let Some(r) = active_root_mut(persistent) {
872        crate::query::actor_move_to_in(&mut r.items, id, x, y, speed);
873    }
874}
875
876/// Delegate for the `actor_follow_cursor` method shared across all three API types.
877fn actor_follow_cursor_into(
878    persistent: &mut crate::threader::Persistent,
879    id: &str,
880    speed: f32,
881    trail: f32,
882) {
883    if let Some(r) = active_root_mut(persistent) {
884        crate::query::actor_follow_cursor_in(&mut r.items, id, speed, trail);
885    }
886}
887
888/// Delegate for the `actor_reset` method shared across all three API types.
889fn actor_reset_into(persistent: &mut crate::threader::Persistent, id: &str) {
890    if let Some(r) = active_root_mut(persistent) {
891        crate::query::actor_reset_in(&mut r.items, id);
892    }
893}
894
895/// Delegate for the `actor_set_pos` method shared across all three API types.
896fn actor_set_pos_into(persistent: &mut crate::threader::Persistent, id: &str, x: f32, y: f32) {
897    if let Some(r) = active_root_mut(persistent) {
898        crate::query::actor_set_pos_in(&mut r.items, id, x, y);
899    }
900}
901
902impl PaneOverlay {
903    /// Forward a winit [`WindowEvent`] to Pane's input handler.
904    pub fn handle_event(&mut self, event: &WindowEvent, pw: f32, ph: f32) {
905        self.input.handle_event(event, pw, ph);
906    }
907
908    /// Forward a gilrs gamepad event manually.
909    ///
910    /// Use this if you own the gilrs instance yourself — pass `None` for `gilrs` in
911    /// [`overlay`] first to prevent Pane from also polling it internally.
912    pub fn handle_gamepad_event(&mut self, event: gilrs::Event) {
913        self.input.handle_gamepad_event(event);
914    }
915
916    /// Stop Pane from polling gilrs internally.
917    pub fn disable_auto_gamepad(&mut self) {
918        self.gilrs = None;
919    }
920
921    /// Tick and render the UI into an existing render pass.
922    ///
923    /// Call once per frame, after your own render commands and before presenting.
924    /// Returns all [`PaneAction`]s emitted since the last call.
925    ///
926    /// - `encoder` — your active command encoder for the current frame.
927    /// - `view` — the texture view to render into (typically your swap chain image).
928    /// - `pw` / `ph` — window dimensions in logical pixels.
929    pub fn draw(
930        &mut self,
931        encoder: &mut wgpu::CommandEncoder,
932        view: &wgpu::TextureView,
933        pw: f32,
934        ph: f32,
935    ) -> Vec<PaneAction> {
936        // Feed external gilrs events before run_frame polls the owned one.
937        if let Some(gilrs) = self.gilrs.as_mut() {
938            while let Some(ev) = gilrs.next_event() {
939                self.input.handle_gamepad_event(ev);
940            }
941        }
942
943        self.persistent.pending_actions.clear();
944
945        let mut frame = crate::threader::Frame {
946            dt: 0.0,
947            last_tick: self.last_time,
948            pw,
949            ph,
950            input: std::mem::take(&mut self.input),
951            scene: std::mem::take(&mut self.scene),
952        };
953        {
954            let mut ctx = crate::threader::FrameCtx {
955                persistent: &mut self.persistent,
956                frame: &mut frame,
957                standalone_pane: None,
958            };
959            crate::order::run_frame(&mut ctx, Some(encoder), Some(view));
960        }
961        self.last_time = frame.last_tick;
962        self.input = frame.input;
963        self.scene = frame.scene;
964
965        std::mem::take(&mut self.persistent.pending_actions)
966    }
967
968    // ── Read ──────────────────────────────────────────────────────────────────
969
970    /// Returns the item data and its current visual state for any widget by id.
971    ///
972    /// Pattern-match on `UiItem` to access widget-specific fields:
973    /// ```ignore
974    /// if let Some((UiItem::Button(b), vis)) = ui.read("my_button") {
975    ///     println!("disabled={} hovered={}", b.disabled, vis.hovered);
976    /// }
977    /// if let Some((UiItem::Slider(s), vis)) = ui.read("volume") {
978    ///     println!("value={} grabbed={}", s.value, vis.grabbed);
979    /// }
980    /// ```
981    pub fn read(&self, id: &str) -> Option<(&crate::items::UiItem, crate::widgets::WidgetState)> {
982        read_into(&self.persistent, id)
983    }
984
985    // ── Write / Set ───────────────────────────────────────────────────────────
986
987    /// Set a widget's value programmatically.
988    ///
989    /// See [`WriteValue`] for the supported widget types and their matching variants.
990    /// Mismatched types and unknown ids are logged and ignored.
991    pub fn write(&mut self, id: &str, value: &WriteValue) {
992        write_into(&mut self.persistent, id, value);
993    }
994
995    // ── Create / Destroy ──────────────────────────────────────────────────────
996
997    /// Add a code-built widget to a named root. Returns `false` if the id is already
998    /// in use or the root doesn't exist.
999    pub fn create(&mut self, root: &str, builder: impl crate::build::WidgetBuilder) -> bool {
1000        create_into(&mut self.persistent, root, builder)
1001    }
1002
1003    /// Remove a widget by id from any root. Returns `true` if a widget was removed.
1004    pub fn destroy(&mut self, id: &str) -> bool {
1005        destroy_into(&mut self.persistent, id)
1006    }
1007
1008    /// Create an empty root that can be populated with [`Self::create`].
1009    pub fn create_root(&mut self, name: impl Into<String>) {
1010        create_root_into(&mut self.persistent, name);
1011    }
1012
1013    /// Resolve a style name to its `StyleId` for use with builders.
1014    #[must_use]
1015    pub fn style_id(&self, name: &str) -> Option<crate::styles::StyleId> {
1016        style_id_in(&self.persistent, name)
1017    }
1018
1019    /// Returns the name of the current default style, if one is set.
1020    #[must_use]
1021    pub fn default_style(&self) -> Option<&str> {
1022        default_style_in(&self.persistent)
1023    }
1024
1025    /// Switch the default style by name. Returns `false` if the name is not registered.
1026    pub fn set_default_style(&mut self, name: &str) -> bool {
1027        set_default_style_in(&mut self.persistent, name)
1028    }
1029
1030    /// Enable or disable debug message logging. When on, every internal `UiMsg` is printed to stdout.
1031    pub const fn set_debug(&mut self, on: bool) {
1032        self.persistent.debug = on;
1033    }
1034
1035    // ── Toast ─────────────────────────────────────────────────────────────────
1036
1037    /// Spawn a transient toast notification at the given position.
1038    pub fn push_toast(
1039        &mut self,
1040        message: impl Into<String>,
1041        duration: f32,
1042        x: f32,
1043        y: f32,
1044        width: f32,
1045        height: f32,
1046    ) {
1047        push_toast_into(
1048            &mut self.persistent,
1049            message.into(),
1050            duration,
1051            x,
1052            y,
1053            width,
1054            height,
1055        );
1056    }
1057
1058    // ── Actor API ─────────────────────────────────────────────────────────────
1059
1060    /// Programmatically send an actor to a fixed position, overriding its RON behaviours.
1061    pub fn actor_move_to(&mut self, id: &str, x: f32, y: f32, speed: f32) {
1062        actor_move_to_into(&mut self.persistent, id, x, y, speed);
1063    }
1064
1065    /// Programmatically make an actor follow the cursor, overriding its RON behaviours.
1066    pub fn actor_follow_cursor(&mut self, id: &str, speed: f32, trail: f32) {
1067        actor_follow_cursor_into(&mut self.persistent, id, speed, trail);
1068    }
1069
1070    /// Clear any programmatic override on an actor, returning it to its RON behaviours.
1071    pub fn actor_reset(&mut self, id: &str) {
1072        actor_reset_into(&mut self.persistent, id);
1073    }
1074
1075    /// Teleport an actor to a position instantly, without lerping.
1076    pub fn actor_set_pos(&mut self, id: &str, x: f32, y: f32) {
1077        actor_set_pos_into(&mut self.persistent, id, x, y);
1078    }
1079}
1080
1081// ── Mode 3: Headless ─────────────────────────────────────────────────────────
1082
1083/// A Pane UI instance with no GPU or window — logic, state, and actions only.
1084///
1085/// Useful for automated testing, server-side UI state machines, or any context
1086/// where rendering is not needed. All widget state is maintained and actions are
1087/// emitted normally; draw calls are discarded.
1088///
1089/// Create with [`headless`], then call [`update`](PaneHeadless::update) each tick.
1090pub struct PaneHeadless {
1091    persistent: crate::threader::Persistent,
1092    input: Input,
1093    scene: Scene,
1094}
1095
1096/// Create a [`PaneHeadless`] instance — no GPU required.
1097///
1098/// Loads the layout from `path` and initialises all widget state. Styles and textures
1099/// are stubbed out; only widget logic runs.
1100///
1101/// Set `headless_accessible: true` in your root definition to enable [`PaneHeadless::press`].
1102///
1103/// # Panics
1104///
1105/// Panics if the `.ron` file cannot be read or parsed.
1106#[must_use]
1107pub fn headless(path: &str) -> PaneHeadless {
1108    let menu = load_menu(path);
1109    let (tx, rx) = mpsc::channel::<UiMsg>();
1110    let dummy_shader = ShaderId::new(0);
1111
1112    // Build a dummy StyleMap — headless mode has no GPU so styles are stubs.
1113    let style_map: StyleMap = crate::styles_reg::BUILTIN_STYLE_NAMES
1114        .iter()
1115        .enumerate()
1116        .map(|(i, name)| {
1117            (
1118                name.to_string(),
1119                (crate::styles::StyleId::new(i), dummy_shader),
1120            )
1121        })
1122        .collect();
1123
1124    let (roots, active_root) = build_logic(&menu, &style_map, dummy_shader, &mut |_, _| None);
1125
1126    let default_style = menu
1127        .default_style
1128        .as_deref()
1129        .and_then(|name| style_map.get(name).map(|&(id, _)| id));
1130
1131    let persistent = crate::threader::Persistent {
1132        roots,
1133        active_root,
1134        tab_pages: std::collections::HashMap::new(),
1135        nav: crate::logic::NavState::default(),
1136        styles: StyleRegistry::new(),
1137        style_map,
1138        default_style,
1139        tex_reg: None,
1140        device: None,
1141        queue: None,
1142        renderer: None,
1143        background: None,
1144        clear_color: None,
1145        pending_actions: Vec::new(),
1146        tx,
1147        rx,
1148        ron_path: path.to_string(),
1149        debug: false,
1150        headless_accessible: menu.headless_accessible,
1151        osk: crate::keyboard::OskState::default(),
1152    };
1153
1154    PaneHeadless {
1155        persistent,
1156        input: Input::new(),
1157        scene: Scene::new(),
1158    }
1159}
1160
1161impl PaneHeadless {
1162    /// Advance the UI by `dt` seconds and return any actions emitted this tick.
1163    ///
1164    /// Call at a fixed or variable rate. `dt` is in seconds; typical values are
1165    /// `1.0 / 60.0` for a 60 Hz simulation tick.
1166    pub fn update(&mut self, dt: f32) -> Vec<PaneAction> {
1167        self.persistent.pending_actions.clear();
1168
1169        // Override last_tick so tick_dt produces exactly the requested dt.
1170        let last_tick = std::time::Instant::now()
1171            .checked_sub(std::time::Duration::from_secs_f32(dt))
1172            .unwrap_or_else(std::time::Instant::now);
1173
1174        let mut frame = crate::threader::Frame {
1175            dt: 0.0,
1176            last_tick,
1177            pw: 0.0,
1178            ph: 0.0,
1179            input: std::mem::take(&mut self.input),
1180            scene: std::mem::take(&mut self.scene),
1181        };
1182        {
1183            let mut ctx = crate::threader::FrameCtx {
1184                persistent: &mut self.persistent,
1185                frame: &mut frame,
1186                standalone_pane: None,
1187            };
1188            crate::order::run_frame(&mut ctx, None, None);
1189        }
1190        self.input = frame.input;
1191        self.scene = frame.scene;
1192
1193        std::mem::take(&mut self.persistent.pending_actions)
1194    }
1195
1196    /// Simulate pressing a button by id.
1197    ///
1198    /// Only works if `headless_accessible: true` is set in the root definition.
1199    /// Fires the button's action as if the user clicked it.
1200    pub fn press(&mut self, id: &str) {
1201        if !self.persistent.headless_accessible {
1202            eprintln!("[pane_ui] press('{id}') ignored — headless_accessible is false");
1203            return;
1204        }
1205        let tx = self.persistent.tx.clone();
1206        let debug = self.persistent.debug;
1207        let Some(name) = self.persistent.active_root.clone() else {
1208            eprintln!("[pane_ui] press('{id}'): no active root");
1209            return;
1210        };
1211        if let Some(root) = self.persistent.roots.get_mut(&name) {
1212            let found = root.items.iter_mut().any(|(item, state)| {
1213                use crate::items::UiItem;
1214                use crate::widgets::ItemState;
1215                if let (UiItem::Toggle(t), ItemState::Toggle(s)) = (&*item, state)
1216                    && t.id == id
1217                {
1218                    {
1219                        s.checked = !s.checked;
1220                        match &t.action {
1221                            crate::loader::ToggleAction::Custom(tag) => {
1222                                let _ =
1223                                    tx.send(crate::loader::UiMsg::Toggle(tag.clone(), s.checked));
1224                            }
1225                            crate::loader::ToggleAction::Print => {
1226                                println!("{}: {}", t.id, s.checked);
1227                            }
1228                        }
1229                        return true;
1230                    }
1231                }
1232                crate::logic::press_in_item(&tx, item, id, debug)
1233            });
1234            if !found {
1235                eprintln!("[pane_ui] press('{id}'): no button found");
1236            }
1237        }
1238    }
1239
1240    /// Returns the name of the currently active root, or `None` if none has been set.
1241    pub fn active_root(&self) -> Option<&str> {
1242        self.persistent.active_root.as_deref()
1243    }
1244
1245    // ── Read ──────────────────────────────────────────────────────────────────
1246
1247    /// Returns the item data and its current visual state for any widget by id.
1248    ///
1249    /// Same as [`PaneOverlay::read`] — pattern-match on `UiItem` to get widget fields.
1250    pub fn read(&self, id: &str) -> Option<(&crate::items::UiItem, crate::widgets::WidgetState)> {
1251        read_into(&self.persistent, id)
1252    }
1253
1254    // ── Write / Set ───────────────────────────────────────────────────────────
1255
1256    /// Set a widget's value programmatically.
1257    ///
1258    /// See [`WriteValue`] for the supported widget types and their matching variants.
1259    /// Mismatched types and unknown ids are logged and ignored.
1260    pub fn write(&mut self, id: &str, value: &WriteValue) {
1261        write_into(&mut self.persistent, id, value);
1262    }
1263
1264    // ── Create / Destroy ──────────────────────────────────────────────────────
1265
1266    /// Add a code-built widget to a named root. Returns `false` if the id is already
1267    /// in use or the root doesn't exist.
1268    pub fn create(&mut self, root: &str, builder: impl crate::build::WidgetBuilder) -> bool {
1269        create_into(&mut self.persistent, root, builder)
1270    }
1271
1272    /// Remove a widget by id from any root. Returns `true` if a widget was removed.
1273    pub fn destroy(&mut self, id: &str) -> bool {
1274        destroy_into(&mut self.persistent, id)
1275    }
1276
1277    /// Create an empty root that can be populated with [`Self::create`].
1278    pub fn create_root(&mut self, name: impl Into<String>) {
1279        create_root_into(&mut self.persistent, name);
1280    }
1281
1282    /// Resolve a style name to its `StyleId` for use with builders.
1283    #[must_use]
1284    pub fn style_id(&self, name: &str) -> Option<crate::styles::StyleId> {
1285        style_id_in(&self.persistent, name)
1286    }
1287
1288    /// Returns the name of the current default style, if one is set.
1289    #[must_use]
1290    pub fn default_style(&self) -> Option<&str> {
1291        default_style_in(&self.persistent)
1292    }
1293
1294    /// Switch the default style by name. Returns `false` if the name is not registered.
1295    pub fn set_default_style(&mut self, name: &str) -> bool {
1296        set_default_style_in(&mut self.persistent, name)
1297    }
1298
1299    /// Enable or disable debug message logging (no-op in headless mode, but
1300    /// kept for API symmetry with [`PaneOverlay::set_debug`]).
1301    pub const fn set_debug(&mut self, on: bool) {
1302        self.persistent.debug = on;
1303    }
1304
1305    // ── Toast ─────────────────────────────────────────────────────────────────
1306
1307    /// Spawn a transient toast notification.
1308    ///
1309    /// In headless mode toasts are tracked but not rendered.
1310    pub fn push_toast(
1311        &mut self,
1312        message: impl Into<String>,
1313        duration: f32,
1314        x: f32,
1315        y: f32,
1316        width: f32,
1317        height: f32,
1318    ) {
1319        push_toast_into(
1320            &mut self.persistent,
1321            message.into(),
1322            duration,
1323            x,
1324            y,
1325            width,
1326            height,
1327        );
1328    }
1329
1330    // ── Actor API ─────────────────────────────────────────────────────────────
1331
1332    /// Programmatically send an actor to a fixed position, overriding its RON behaviours.
1333    pub fn actor_move_to(&mut self, id: &str, x: f32, y: f32, speed: f32) {
1334        actor_move_to_into(&mut self.persistent, id, x, y, speed);
1335    }
1336
1337    /// Programmatically make an actor follow the cursor, overriding its RON behaviours.
1338    pub fn actor_follow_cursor(&mut self, id: &str, speed: f32, trail: f32) {
1339        actor_follow_cursor_into(&mut self.persistent, id, speed, trail);
1340    }
1341
1342    /// Clear any programmatic override on an actor, returning it to its RON behaviours.
1343    pub fn actor_reset(&mut self, id: &str) {
1344        actor_reset_into(&mut self.persistent, id);
1345    }
1346
1347    /// Teleport an actor to a position instantly, without lerping.
1348    pub fn actor_set_pos(&mut self, id: &str, x: f32, y: f32) {
1349        actor_set_pos_into(&mut self.persistent, id, x, y);
1350    }
1351}