Skip to main content

oxiui/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4//! `oxiui` — Pure-Rust GUI facade.
5//!
6//! **Default features:** `["gpu","egui"]` — boots an egui app rendered via wgpu.
7//! GPU drivers (Vulkan/Metal/DX12) are OS-provided at runtime; they do NOT appear
8//! in `cargo tree --edges normal` (GOVERNANCE §8 bullet 2).
9//!
10//! **Headless / ffi-audit path:** `--no-default-features --features software`
11//! uses a softbuffer CPU framebuffer; no GPU stack required at build time.
12//!
13//! **iced backend:** Enable with `--features iced`. The iced backend wires
14//! the `content` closure through `oxiui_iced::IcedUiCtx` using iced's
15//! retained-mode Elm-style update/view loop. Button clicks from one frame are
16//! reflected as `ButtonResponse::clicked = true` in the next frame's view call
17//! (one-frame latency, inherent to the retained-mode / immediate-mode bridge).
18//!
19//! **slint backend:** Enable with `--features slint`. The slint backend wires
20//! the `content` closure through `oxiui_slint::SlintCtx`. In M5, this operates
21//! in headless collection mode (no display required). Native window rendering
22//! via `slint::run_event_loop()` is deferred to M6. Note: slint 1.16.1 is
23//! GPL-3.0 OR royalty-free OR commercial licensed; only pulled in under this
24//! explicit feature gate.
25//!
26//! **dioxus backend:** Enable with `--features dioxus`. The dioxus backend wires
27//! the `content` closure through `oxiui_dioxus::DioxusCtx`. In M5, this operates
28//! in headless collection mode. The `minimal` dioxus feature set is used (Pure
29//! Rust); the `desktop` feature (wry/tao/WebKit, C/C++ deps) is excluded.
30//!
31//! **GOVERNANCE §6 note:** The `default = ["gpu","egui"]` facade deviation from
32//! the strict tier-1 `default = []` rule is authorized by ADAPTER_PATTERN §3
33//! rule 4 (a zero-feature facade build must select at least one Pure adapter).
34//! Parallel precedents: `oxicrypto`'s `default = ["pure"]`, `oxitls`'s
35//! `default = ["pure","webpki-roots"]`.
36//!
37//! # Quick start (egui)
38//!
39//! ```rust,no_run
40//! use oxiui::{App, AppConfig};
41//! App::new(AppConfig::new().title("Hello OxiUI"))
42//!     .theme(oxiui::theme::cooljapan_default())
43//!     .content(|ui| {
44//!         ui.heading("Hello, world!");
45//!         if ui.button("Quit").clicked { /* exit logic */ }
46//!     })
47//!     .run()
48//!     .expect("UI error");
49//! ```
50//!
51//! # Quick start (iced backend)
52//!
53//! ```rust,ignore
54//! use oxiui::{App, AppConfig, Backend};
55//! App::new(AppConfig::new().title("Hello OxiUI (iced)"))
56//!     .theme(oxiui::theme::cooljapan_default())
57//!     .backend(Backend::Iced)
58//!     .content(|ui| {
59//!         ui.heading("Hello from iced!");
60//!         if ui.button("Quit").clicked { std::process::exit(0); }
61//!     })
62//!     .run()
63//!     .expect("UI error");
64//! ```
65//!
66//! Or use the standalone example:
67//! ```sh
68//! cargo run --example hello_iced --features iced -p oxiui
69//! ```
70
71pub use oxiui_core::{ButtonResponse, Color, FontSpec, Palette, Theme, UiCtx, UiError};
72
73/// Pluggable backend runner infrastructure.
74///
75/// Provides the `BackendRunner` trait and its built-in implementations
76/// (`EguiRunner` behind `egui` feature, `IcedRunner` behind `iced` feature)
77/// for wiring custom backend dispatchers.
78pub mod runner;
79
80/// Multi-window support.
81///
82/// Provides [`multiwindow::WindowRegistry`], [`multiwindow::SecondaryWindow`],
83/// and the [`App::open_window`] / [`App::close_window`] builder methods for
84/// registering secondary windows.  The underlying [`oxiui_core::window::WindowId`]
85/// and [`oxiui_core::window::WindowChannel`] types are also re-exported here.
86pub mod multiwindow;
87
88pub use multiwindow::SecondaryWindow;
89
90/// In-app dialog queue (no OS file picker; Pure Rust).
91///
92/// Provides [`dialog::DialogQueue`], [`dialog::DialogKind`],
93/// [`dialog::DialogResponse`], and [`dialog::DialogId`] for a
94/// backend-agnostic dialog request/response model.
95pub mod dialog;
96
97pub use dialog::{DialogId, DialogKind, DialogQueue, DialogResponse};
98
99/// Native menu bar builder.
100///
101/// Provides [`menu::MenuBar`], [`menu::MenuBarBuilder`], [`menu::Menu`], and
102/// [`menu::MenuItem`] for constructing cross-platform application menu bars.
103/// Use [`App::with_menu_bar`] to attach a menu bar to the running app.
104pub mod menu;
105
106pub use menu::{Menu, MenuBar, MenuBarBuilder, MenuItem};
107
108#[cfg(feature = "egui")]
109#[cfg_attr(docsrs, doc(cfg(feature = "egui")))]
110pub use runner::EguiRunner;
111#[cfg(feature = "iced")]
112#[cfg_attr(docsrs, doc(cfg(feature = "iced")))]
113pub use runner::IcedRunner;
114pub use runner::{BackendRunner, LifecycleConfig};
115
116/// Logging / tracing integration (requires `tracing` feature).
117///
118/// Provides [`logging::init_logging`] which installs a `tracing-subscriber` fmt subscriber
119/// configured to respect the `RUST_LOG` environment variable.  Call this early
120/// in `main()` to get structured, colourised log output for all OxiUI tracing
121/// spans (frame, layout, paint, event).
122///
123/// # Example
124///
125/// ```no_run
126/// fn main() {
127///     oxiui::logging::init_logging(oxiui::logging::LogLevel::Info);
128///     // … start the app
129/// }
130/// ```
131#[cfg(feature = "tracing")]
132#[cfg_attr(docsrs, doc(cfg(feature = "tracing")))]
133pub mod logging;
134
135/// PNG icon decoding (internal; requires `egui` feature which pulls in `png`).
136#[cfg(feature = "egui")]
137pub(crate) mod icon;
138
139/// Built-in theme picker widget.
140///
141/// Provides `theme_picker` and [`BUILTIN_THEMES`] for constructing a simple
142/// UI to switch between the OxiUI built-in themes at runtime.
143pub mod theme_picker;
144
145pub use theme_picker::{by_name as theme_by_name, theme_picker, BUILTIN_THEMES};
146
147/// Re-exports of the COOLJAPAN theme constructors.
148pub mod theme {
149    pub use oxiui_theme::{cooljapan_default, dark, light};
150}
151
152/// Table widget re-exports (requires `table` feature).
153#[cfg(feature = "table")]
154#[cfg_attr(docsrs, doc(cfg(feature = "table")))]
155pub mod table {
156    pub use oxiui_table::*;
157}
158
159/// Accessibility tree builder re-exports (requires `a11y` feature).
160///
161/// Provides `A11yTree`, `A11yNode`, and `WidgetRole` for building
162/// accesskit `TreeUpdate` objects from the OxiUI widget graph. The tree is
163/// headless-testable: no display server is required to build or inspect it.
164#[cfg(feature = "a11y")]
165#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
166pub mod accessibility {
167    pub use oxiui_accessibility::{A11yNode, A11yTree, WidgetRole};
168}
169
170/// Headless recording context for capturing widget calls as accessibility entries.
171///
172/// Exposes [`RecordingUiCtx`] and [`RecordingEntry`] for building an
173/// [`oxiui_accessibility::A11yTree`] snapshot from a content closure without
174/// opening a real window. Requires the `a11y` feature.
175#[cfg(feature = "a11y")]
176#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
177pub mod recording;
178
179#[cfg(feature = "a11y")]
180#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
181pub use recording::{RecordingEntry, RecordingUiCtx};
182
183/// Re-exports from `oxiui-render-soft` (requires `software` feature).
184///
185/// Exposes the pure-CPU headless render path: [`render::RgbaBuffer`],
186/// [`render::render_headless_once`], and [`render::render_headless_scene`].
187#[cfg(feature = "software")]
188#[cfg_attr(docsrs, doc(cfg(feature = "software")))]
189pub mod render {
190    pub use oxiui_render_soft::{
191        render_headless_once, render_headless_scene, Framebuffer, RgbaBuffer,
192    };
193}
194
195/// Re-exports from `oxiui-core` text/font types.
196///
197/// Exposes [`text::FontSpec`] and [`text::FontStyle`] for convenience.
198pub mod text {
199    pub use oxiui_core::{FontFeature, FontSpec, FontStyle};
200}
201
202/// Constraint solver types re-exported from oxiui-core.
203pub mod solver {
204    pub use oxiui_core::{
205        Constraint, Expression, RelOp, Solver, SolverError, Strength, Term, Variable,
206    };
207}
208
209/// System tray integration (requires `tray` feature).
210///
211/// Provides [`tray::TrayConfig`], [`tray::TrayMenuItem`], and [`tray::TrayHandle`]
212/// for registering a system tray icon with a context menu.  The icon is backed by
213/// the [`tray-icon`](https://crates.io/crates/tray-icon) crate at runtime.
214///
215/// # Note (basic implementation)
216///
217/// This is a basic implementation.  Full event-loop integration (receiving menu-click
218/// callbacks inside the eframe/iced event loop) is planned for a future release.
219pub mod tray;
220
221pub use tray::{TrayConfig, TrayHandle, TrayMenuItem};
222
223/// Native OS file / message dialogs (requires `dialogs` feature).
224///
225/// Provides [`native_dialog::open_file_dialog`], [`native_dialog::save_file_dialog`],
226/// [`native_dialog::message_dialog`], and [`native_dialog::confirm_dialog`] backed
227/// by the [`rfd`](https://crates.io/crates/rfd) Pure-Rust crate.
228///
229/// These are *blocking* helpers that call the platform's native dialog API.
230/// For a headless-compatible alternative, use the built-in [`dialog::DialogQueue`].
231pub mod native_dialog;
232
233pub use native_dialog::{DialogResult, MessageLevel};
234
235/// Prelude module — re-exports the most commonly used OxiUI types.
236///
237/// Add `use oxiui::prelude::*;` to get all commonly needed types in scope.
238pub mod prelude {
239    pub use crate::{App, AppConfig, AppExit, Backend, HotkeyConflict, Notification, Plugin};
240    pub use oxiui_core::{AlignContent, FlexWrap, RichTextSpan};
241    pub use oxiui_core::{ButtonResponse, Color, UiCtx, UiError};
242    pub use oxiui_core::{Computed, ReactiveError, ReactiveRuntime, Signal};
243    pub use oxiui_core::{Point, Rect, Size};
244    pub use oxiui_theme::CooljapanTheme;
245}
246
247/// Core type re-exports.
248pub mod core {
249    pub use oxiui_core::*;
250}
251
252/// Fine-grained reactive state primitives.
253///
254/// Provides `Signal`, `Computed`, `ReactiveRuntime`, and `ReactiveError`
255/// from `oxiui-core`. Use these to build data-driven UI state without manually
256/// tracking dirty flags.
257pub mod reactive {
258    pub use oxiui_core::{Computed, ReactiveError, ReactiveRuntime, Signal};
259}
260
261/// Window configuration builder.
262pub mod app_config;
263pub use app_config::AppConfig;
264
265/// In-app toast notification queue.
266pub mod notification;
267pub use notification::{Notification, NotificationQueue};
268
269/// Searchable command palette.
270pub mod command;
271pub use command::{Command, CommandPalette};
272
273/// Available GUI backend choices for [`App`].
274///
275/// The default backend is [`Backend::Egui`]. Select `Backend::Iced` (requires
276/// the `iced` feature) to use the iced retained-mode framework.
277/// `Backend::Slint` and `Backend::Dioxus` are experimental adapters added in M5.
278#[derive(Clone, Debug, Default)]
279pub enum Backend {
280    /// egui + eframe (immediate-mode, default).
281    #[default]
282    Egui,
283    /// iced (retained-mode, Elm-style). Requires `--features iced`.
284    ///
285    /// The content closure is driven through `IcedUiCtx` each frame. Button
286    /// clicks carry a one-frame latency (inherent to retained-mode bridging).
287    #[cfg(feature = "iced")]
288    Iced,
289    /// slint GUI toolkit adapter. Requires `--features slint`.
290    ///
291    /// In M5, operates in headless collection mode. Native window rendering
292    /// via `slint::run_event_loop()` is planned for M6.
293    ///
294    /// **License:** slint is GPL-3.0 OR royalty-free OR commercial. Enable
295    /// only in projects that are compatible with one of those license options.
296    #[cfg(feature = "slint")]
297    Slint,
298    /// Dioxus reactive UI adapter. Requires `--features dioxus`.
299    ///
300    /// In M5, operates in headless collection mode. Full Dioxus native rendering
301    /// via `dioxus-native` (Pure Rust Blitz renderer) is planned for M6.
302    #[cfg(feature = "dioxus")]
303    Dioxus,
304}
305
306/// Application exit status.
307///
308/// Returned by [`App::run`] when the event loop terminates normally.
309///
310/// The `RequestedByUser` variant covers the common case of the user explicitly
311/// closing the window. `Programmatic(reason)` is used when code calls a
312/// controlled shutdown with an explanatory string. `Ok` is returned by the
313/// headless path and by backends that do not distinguish how the loop ended.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum AppExit {
316    /// The application exited normally (user closed the window or event loop drained).
317    Ok,
318    /// The application exited due to an error.
319    Error(String),
320    /// The user explicitly requested exit (e.g. clicked the close button).
321    RequestedByUser,
322    /// Programmatic shutdown with an explanatory reason string.
323    Programmatic(String),
324}
325
326/// Error returned when two hotkeys share the same `(Modifiers, Key)` pair.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct HotkeyConflict {
329    /// Human-readable description of the conflicting binding.
330    pub message: String,
331}
332
333impl std::fmt::Display for HotkeyConflict {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        write!(f, "HotkeyConflict: {}", self.message)
336    }
337}
338
339impl std::error::Error for HotkeyConflict {}
340
341/// Boxed content closure type for an OxiUI app frame.
342type ContentFn = Box<dyn FnMut(&mut dyn oxiui_core::UiCtx) + Send>;
343
344/// Boxed lifecycle hook closure.
345type HookFn = Box<dyn FnMut(&mut dyn oxiui_core::UiCtx) + Send + Sync>;
346
347/// Type alias for an egui escape-hatch callback (avoids `type_complexity` lint).
348#[cfg(feature = "egui")]
349type EguiFrameHook = Box<dyn FnMut(&egui::Context) + Send>;
350
351// ─── Plugin trait ────────────────────────────────────────────────────────────
352
353/// A plugin that receives lifecycle callbacks from the [`App`] event loop.
354///
355/// Plugins are registered via [`App::plugin`] and called in ascending
356/// [`Plugin::priority`] order (lower number = earlier call).
357///
358/// # Example
359///
360/// ```rust
361/// use oxiui::{App, AppConfig};
362/// use oxiui::Plugin;
363/// use oxiui_core::UiCtx;
364///
365/// struct LogPlugin;
366/// impl Plugin for LogPlugin {
367///     fn init(&mut self, _ctx: &mut dyn UiCtx) {}
368///     fn update(&mut self, _ctx: &mut dyn UiCtx) {}
369/// }
370///
371/// let _app = App::new(AppConfig::new().title("test"))
372///     .plugin(LogPlugin);
373/// ```
374pub trait Plugin: Send + Sync {
375    /// Called once when the app initialises (before the first frame).
376    fn init(&mut self, ctx: &mut dyn UiCtx);
377    /// Called every frame after the content closure.
378    fn update(&mut self, ctx: &mut dyn UiCtx);
379    /// Plugin priority — lower numbers are called first. Default: `0`.
380    fn priority(&self) -> i32 {
381        0
382    }
383}
384
385// ─── Hotkey registry ─────────────────────────────────────────────────────────
386
387use oxiui_core::events::{Key, Modifiers};
388
389/// A single registered hotkey binding.
390pub struct HotkeyBinding {
391    /// Unique identifier for this binding.
392    pub id: String,
393    /// Modifier keys required.
394    pub modifiers: Modifiers,
395    /// Logical key required.
396    pub key: Key,
397    /// Action to invoke when the hotkey fires.
398    pub action: Box<dyn Fn() + Send + Sync>,
399}
400
401/// A registry of keyboard hotkey bindings.
402///
403/// Enforces that no two bindings share the same `(Modifiers, Key)` pair.
404pub struct HotkeyRegistry {
405    bindings: Vec<HotkeyBinding>,
406}
407
408impl HotkeyRegistry {
409    /// Create an empty [`HotkeyRegistry`].
410    pub fn new() -> Self {
411        Self {
412            bindings: Vec::new(),
413        }
414    }
415
416    /// Register a hotkey binding.
417    ///
418    /// Returns `Err` if another binding with the same `(mods, key)` pair
419    /// is already registered.
420    pub fn register(
421        &mut self,
422        id: impl Into<String>,
423        mods: Modifiers,
424        key: Key,
425        action: impl Fn() + Send + Sync + 'static,
426    ) -> Result<(), String> {
427        if self.conflict_check(mods, key.clone()) {
428            return Err(format!("hotkey conflict: {mods:?}+{key:?}"));
429        }
430        self.bindings.push(HotkeyBinding {
431            id: id.into(),
432            modifiers: mods,
433            key,
434            action: Box::new(action),
435        });
436        Ok(())
437    }
438
439    /// Returns `true` if a binding with this `(mods, key)` pair is already registered.
440    pub fn conflict_check(&self, mods: Modifiers, key: Key) -> bool {
441        self.bindings
442            .iter()
443            .any(|b| b.modifiers == mods && b.key == key)
444    }
445
446    /// The number of registered bindings.
447    pub fn len(&self) -> usize {
448        self.bindings.len()
449    }
450
451    /// Returns `true` if no bindings are registered.
452    pub fn is_empty(&self) -> bool {
453        self.bindings.is_empty()
454    }
455}
456
457impl Default for HotkeyRegistry {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463// ─── iced backend (extracted to iced_backend.rs for line-count compliance) ────
464#[cfg(feature = "iced")]
465mod iced_backend;
466// Re-export as `iced_app` to preserve existing call-sites in run_iced().
467#[cfg(feature = "iced")]
468use iced_backend as iced_app;
469
470// ─── App builder ─────────────────────────────────────────────────────────────
471
472/// A builder for an OxiUI application window.
473///
474/// Create with [`App::new`], configure with the builder methods, then call
475/// [`App::run`] or [`App::run_headless_once`].
476pub struct App {
477    config: AppConfig,
478    theme: Box<dyn oxiui_core::Theme>,
479    content: Option<ContentFn>,
480    backend: Backend,
481    on_init: Vec<HookFn>,
482    on_frame: Vec<HookFn>,
483    on_close: Vec<HookFn>,
484    on_resize: Vec<HookFn>,
485    on_focus: Vec<HookFn>,
486    plugins: Vec<Box<dyn Plugin>>,
487    hotkeys: HotkeyRegistry,
488    commands: CommandPalette,
489    notifications: NotificationQueue,
490    /// When `true`, the egui backend will yield CPU when no input events occurred.
491    frame_skip: bool,
492    /// Per-frame escape-hatch callbacks that receive the raw [`egui::Context`].
493    #[cfg(feature = "egui")]
494    egui_frame_hooks: Vec<EguiFrameHook>,
495    /// Multi-window registry: secondary windows registered via [`App::open_window`].
496    window_registry: multiwindow::WindowRegistry,
497    /// In-app dialog queue for pending dialog requests and responses.
498    dialogs: dialog::DialogQueue,
499    /// Optional application-level menu bar.
500    menu_bar: Option<menu::MenuBar>,
501}
502
503impl App {
504    /// Create a new [`App`] with the given [`AppConfig`].
505    pub fn new(config: AppConfig) -> Self {
506        Self {
507            config,
508            theme: oxiui_theme::cooljapan_default(),
509            content: None,
510            backend: Backend::default(),
511            on_init: Vec::new(),
512            on_frame: Vec::new(),
513            on_close: Vec::new(),
514            on_resize: Vec::new(),
515            on_focus: Vec::new(),
516            plugins: Vec::new(),
517            hotkeys: HotkeyRegistry::new(),
518            commands: CommandPalette::new(),
519            notifications: NotificationQueue::new(),
520            frame_skip: false,
521            #[cfg(feature = "egui")]
522            egui_frame_hooks: Vec::new(),
523            window_registry: multiwindow::WindowRegistry::new(),
524            dialogs: dialog::DialogQueue::new(),
525            menu_bar: None,
526        }
527    }
528
529    /// Set the UI theme.
530    pub fn theme(mut self, theme: Box<dyn oxiui_core::Theme>) -> Self {
531        self.theme = theme;
532        self
533    }
534
535    /// Set the content closure that will be called every frame.
536    pub fn content<F>(mut self, f: F) -> Self
537    where
538        F: FnMut(&mut dyn oxiui_core::UiCtx) + Send + 'static,
539    {
540        self.content = Some(Box::new(f));
541        self
542    }
543
544    /// Select the GUI backend.
545    ///
546    /// Defaults to [`Backend::Egui`]. To use iced, enable the `iced` feature
547    /// and pass `Backend::Iced`.
548    pub fn backend(mut self, backend: Backend) -> Self {
549        self.backend = backend;
550        self
551    }
552
553    // ─── Window config builder methods ───────────────────────────────────────
554
555    /// Set the minimum window size in logical pixels.
556    pub fn min_size(mut self, w: f32, h: f32) -> Self {
557        self.config.min_size = Some((w, h));
558        self
559    }
560
561    /// Set the maximum window size in logical pixels.
562    pub fn max_size(mut self, w: f32, h: f32) -> Self {
563        self.config.max_size = Some((w, h));
564        self
565    }
566
567    /// Set whether the window has OS-drawn decorations (title bar, borders).
568    pub fn decorations(mut self, d: bool) -> Self {
569        self.config.decorations = d;
570        self
571    }
572
573    /// Set whether the window background is transparent.
574    pub fn transparent(mut self, t: bool) -> Self {
575        self.config.transparent = t;
576        self
577    }
578
579    /// Set whether the window is always shown above other windows.
580    pub fn always_on_top(mut self, a: bool) -> Self {
581        self.config.always_on_top = a;
582        self
583    }
584
585    /// Set the window icon from raw PNG/ICO bytes.
586    pub fn icon(mut self, bytes: Vec<u8>) -> Self {
587        self.config.icon = Some(bytes);
588        self
589    }
590
591    /// Set the initial window position in logical pixels from the primary monitor top-left.
592    pub fn position(mut self, x: f32, y: f32) -> Self {
593        self.config.position = Some((x, y));
594        self
595    }
596
597    /// Load a custom font family into all backends.
598    ///
599    /// The font bytes are stored in [`AppConfig::extra_fonts`] and forwarded
600    /// to the active backend's font-loading path when [`App::run`] begins.
601    /// In this release, font loading is wired into the egui backend path only;
602    /// the iced path stores the bytes but font registration is deferred.
603    pub fn with_font(mut self, family_name: impl Into<String>, bytes: Vec<u8>) -> Self {
604        self.config.extra_fonts.push((family_name.into(), bytes));
605        self
606    }
607
608    /// Configure the app with a stateful content closure.
609    ///
610    /// The `state` value is owned by the closure and passed by mutable reference
611    /// on each frame. Because `ContentFn` requires `Send`, the state must be
612    /// `Send + 'static`.
613    ///
614    /// This replaces any previously set content closure.
615    ///
616    /// # Example
617    ///
618    /// ```rust
619    /// use oxiui::{App, AppConfig};
620    ///
621    /// let _app = App::new(AppConfig::default())
622    ///     .with_state(0i32, |ui, count| {
623    ///         ui.label(&format!("Count: {count}"));
624    ///         *count += 1;
625    ///     });
626    /// ```
627    pub fn with_state<State: Send + 'static>(
628        mut self,
629        state: State,
630        mut content: impl FnMut(&mut dyn oxiui_core::UiCtx, &mut State) + Send + 'static,
631    ) -> Self {
632        let mut inner_state = state;
633        let content_fn = move |ui: &mut dyn oxiui_core::UiCtx| {
634            content(ui, &mut inner_state);
635        };
636        self.content = Some(Box::new(content_fn));
637        self
638    }
639
640    // ─── Frame-skipping and egui escape hatch ────────────────────────────────
641
642    /// Enable or disable frame skipping in the egui backend.
643    ///
644    /// When `enabled` is `true`, the egui backend will call
645    /// `egui::Context::request_repaint_after` with a 1-second delay whenever
646    /// no input events occurred in that frame, yielding CPU time. This is a
647    /// conservative "dirty flag" optimisation for apps that animate infrequently.
648    ///
649    /// Defaults to `false` (egui's own repaint-on-input model is sufficient for
650    /// most apps without this).
651    pub fn with_frame_skip(mut self, enabled: bool) -> Self {
652        self.frame_skip = enabled;
653        self
654    }
655
656    /// Register a per-frame callback that receives the raw [`egui::Context`].
657    ///
658    /// The callback is invoked once per frame from inside `OxiEguiApp::ui` after
659    /// the content closure has run. This is an escape hatch for egui-specific
660    /// operations (e.g., loading textures, accessing the raw style, or using
661    /// egui widgets not yet exposed through [`UiCtx`]).
662    ///
663    /// Requires the `egui` feature.
664    #[cfg(feature = "egui")]
665    #[cfg_attr(docsrs, doc(cfg(feature = "egui")))]
666    pub fn with_egui_ctx(mut self, f: impl FnMut(&egui::Context) + Send + 'static) -> Self {
667        self.egui_frame_hooks.push(Box::new(f));
668        self
669    }
670
671    // ─── Table convenience ────────────────────────────────────────────────────
672
673    /// Embed a table view as the app's content.
674    ///
675    /// The table is rendered frame-by-frame through the active [`UiCtx`] by
676    /// iterating the [`oxiui_table::RowSource`] and calling [`UiCtx::label`] for
677    /// each cell. The source is wrapped in `Arc<Mutex<S>>` so it can be shared
678    /// across frames from a `Send + 'static` closure.
679    ///
680    /// **Note:** For advanced table features (column sorting, resizing, filtering)
681    /// use [`oxiui_table::Table`] directly inside a `content` closure with the
682    /// `render_egui` / `render_iced` backend-specific methods.
683    ///
684    /// Requires the `table` feature.
685    #[cfg(feature = "table")]
686    #[cfg_attr(docsrs, doc(cfg(feature = "table")))]
687    pub fn table<S: oxiui_table::RowSource + Send + 'static>(mut self, source: S) -> Self {
688        let source = std::sync::Arc::new(std::sync::Mutex::new(source));
689        self = self.content(move |ui| {
690            if let Ok(src) = source.lock() {
691                // Render column headers.
692                for col in src.column_defs() {
693                    ui.label(col.name.as_str());
694                }
695                // Render each row's cells.
696                let row_count = src.row_count();
697                for i in 0..row_count {
698                    let cells = src.row(i);
699                    for cell in &cells {
700                        ui.label(&cell.to_string());
701                    }
702                }
703            }
704        });
705        self
706    }
707
708    // ─── Lifecycle hooks ──────────────────────────────────────────────────────
709
710    /// Register a closure to be called once when the app initialises.
711    ///
712    /// Multiple `on_init` hooks are called in registration order.
713    pub fn on_init<F>(mut self, f: F) -> Self
714    where
715        F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
716    {
717        self.on_init.push(Box::new(f));
718        self
719    }
720
721    /// Register a closure to be called every frame after the content closure.
722    ///
723    /// Multiple `on_frame` hooks are called in registration order.
724    pub fn on_frame<F>(mut self, f: F) -> Self
725    where
726        F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
727    {
728        self.on_frame.push(Box::new(f));
729        self
730    }
731
732    /// Register a closure to be called when the window is closed.
733    ///
734    /// Invoked on the egui-path inside `OxiEguiApp` (not yet on iced-path;
735    /// iced has no per-close callback surface in 0.14). In headless mode this
736    /// hook is never fired (there is no window to close).
737    pub fn on_close<F>(mut self, f: F) -> Self
738    where
739        F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
740    {
741        self.on_close.push(Box::new(f));
742        self
743    }
744
745    /// Register a closure to be called when the window is resized.
746    ///
747    /// Currently stored and available for inspection; egui and iced do not yet
748    /// expose a per-resize callback in the same form — this hook is fired from
749    /// the headless path for testability and will be wired into the real backends
750    /// once the event surface is stable.
751    pub fn on_resize<F>(mut self, f: F) -> Self
752    where
753        F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
754    {
755        self.on_resize.push(Box::new(f));
756        self
757    }
758
759    /// Register a closure to be called when the window gains or loses focus.
760    ///
761    /// Same status as `on_resize` — stored, testable, not yet wired into live backends.
762    pub fn on_focus<F>(mut self, f: F) -> Self
763    where
764        F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
765    {
766        self.on_focus.push(Box::new(f));
767        self
768    }
769
770    // ─── Plugin registry ──────────────────────────────────────────────────────
771
772    /// Register a plugin.
773    ///
774    /// Plugins are sorted by [`Plugin::priority`] (ascending) before use, so
775    /// lower-priority-number plugins are initialised and updated first.
776    pub fn plugin<P: Plugin + 'static>(mut self, p: P) -> Self {
777        self.plugins.push(Box::new(p));
778        self
779    }
780
781    // ─── Feature APIs ─────────────────────────────────────────────────────────
782
783    /// Enqueue an in-app toast notification.
784    ///
785    /// - `urgency`: 0 = low (3 s), 1 = normal (5 s), 2 = critical (10 s).
786    pub fn notify(
787        mut self,
788        title: impl Into<String>,
789        body: impl Into<String>,
790        urgency: u8,
791    ) -> Self {
792        self.notifications.enqueue(title, body, urgency);
793        self
794    }
795
796    /// Register a global hotkey binding.
797    ///
798    /// Returns `Err(HotkeyConflict)` if the same `(mods, key)` pair is already
799    /// registered. The error is returned wrapped in the builder to allow
800    /// chaining — call `.hotkey(...)` on the `Result<App, HotkeyConflict>`.
801    ///
802    /// # Example
803    ///
804    /// ```rust
805    /// use oxiui::{App, AppConfig};
806    /// use oxiui_core::events::{Key, Modifiers};
807    ///
808    /// let app = App::new(AppConfig::new())
809    ///     .try_hotkey(Modifiers { ctrl: true, ..Modifiers::NONE }, Key::Character("s".into()), "save")
810    ///     .expect("no conflict");
811    /// ```
812    pub fn try_hotkey(
813        mut self,
814        mods: Modifiers,
815        key: Key,
816        action: impl Into<String>,
817    ) -> Result<Self, HotkeyConflict> {
818        let action_str: String = action.into();
819        self.hotkeys
820            .register(action_str.clone(), mods, key, move || {})
821            .map_err(|message| HotkeyConflict { message })?;
822        Ok(self)
823    }
824
825    /// Register a searchable command in the command palette.
826    ///
827    /// # Example
828    ///
829    /// ```rust
830    /// use oxiui::{App, AppConfig};
831    ///
832    /// let app = App::new(AppConfig::new())
833    ///     .register_command("Save File", None);
834    /// ```
835    pub fn register_command(mut self, name: impl Into<String>, shortcut: Option<String>) -> Self {
836        let name: String = name.into();
837        self.commands
838            .register_with_shortcut(name.clone(), name, shortcut, || {});
839        self
840    }
841
842    /// Fuzzy-search the command palette and return matching command labels.
843    ///
844    /// Returns labels (not IDs) of commands whose label subsequence-matches `query`.
845    pub fn command_matches(&self, query: &str) -> Vec<String> {
846        self.commands
847            .search(query)
848            .into_iter()
849            .map(|c| c.label.clone())
850            .collect()
851    }
852
853    /// Capture a screenshot as raw PNG bytes using the software render path.
854    ///
855    /// When the `software` feature is enabled, this renders a headless frame at the
856    /// configured window dimensions and encodes the result as PNG. When `software` is
857    /// not enabled, returns `Err(UiError::Unsupported)`.
858    ///
859    /// # Errors
860    ///
861    /// - `UiError::Unsupported` when the `software` feature is not enabled.
862    /// - `UiError::Backend(msg)` if PNG encoding fails.
863    pub fn screenshot(&self) -> Result<Vec<u8>, UiError> {
864        #[cfg(feature = "software")]
865        {
866            let w = if self.config.width > 0.0 {
867                self.config.width as u32
868            } else {
869                800
870            };
871            let h = if self.config.height > 0.0 {
872                self.config.height as u32
873            } else {
874                600
875            };
876            let buf = oxiui_render_soft::headless::render_headless_once(w, h);
877            // Use RgbaBuffer::save_png to write to a temp file, then read back as bytes.
878            // This avoids a direct `png` crate dependency in the oxiui facade.
879            let tmp_path = std::env::temp_dir().join(format!("oxiui_screenshot_{w}x{h}.png"));
880            buf.save_png(&tmp_path)
881                .map_err(|e| UiError::Backend(e.to_string()))?;
882            let bytes = std::fs::read(&tmp_path).map_err(|e| UiError::Backend(e.to_string()))?;
883            let _ = std::fs::remove_file(&tmp_path);
884            Ok(bytes)
885        }
886        #[cfg(not(feature = "software"))]
887        Err(UiError::Unsupported(
888            "App::screenshot() requires the `software` feature to be enabled.".to_string(),
889        ))
890    }
891
892    /// Run one headless frame and return the value produced by `content`.
893    ///
894    /// This is the headless-only variant: `content` is called against a `NullUiCtx`
895    /// and its return value is forwarded. The real (native-window) backends are not
896    /// supported here — they return `Err(UiError::Unsupported)` with a note.
897    ///
898    /// # Errors
899    ///
900    /// - `UiError::Unsupported` when called on a non-headless app (use
901    ///   [`App::run_headless_once`] + a shared-state closure for that case).
902    pub fn run_with_return<T>(
903        self,
904        content: impl FnOnce(&mut dyn UiCtx) -> T + 'static,
905    ) -> Result<T, UiError> {
906        struct NullUiCtx;
907        impl UiCtx for NullUiCtx {
908            fn heading(&mut self, _text: &str) {}
909            fn label(&mut self, _text: &str) {}
910            fn button(&mut self, _label: &str) -> ButtonResponse {
911                ButtonResponse::default()
912            }
913        }
914
915        let mut null = NullUiCtx;
916        let result = content(&mut null);
917        Ok(result)
918    }
919
920    // ─── Accessors for registries (read-only borrows) ─────────────────────────
921
922    /// Inspect the notification queue (e.g. for testing `App::notify`).
923    pub fn notifications(&self) -> &NotificationQueue {
924        &self.notifications
925    }
926
927    /// Inspect the hotkey registry (e.g. for testing `App::try_hotkey`).
928    pub fn hotkeys(&self) -> &HotkeyRegistry {
929        &self.hotkeys
930    }
931
932    /// Inspect the extra fonts registered via [`App::with_font`].
933    ///
934    /// Returns a slice of `(family_name, bytes)` pairs in registration order.
935    pub fn extra_fonts(&self) -> &[(String, Vec<u8>)] {
936        &self.config.extra_fonts
937    }
938
939    // ─── oxiui-theme design-token / typography integration ────────────────────
940
941    /// Override the active theme's design tokens (spacing, radius, elevation).
942    ///
943    /// The tokens are stored in [`AppConfig`] and available via
944    /// [`App::design_tokens`] for advanced backends and layout engines that need
945    /// to read the spacing scale at frame time.
946    ///
947    /// # Example
948    ///
949    /// ```rust
950    /// use oxiui::{App, AppConfig};
951    /// use oxiui_theme::DesignTokens;
952    ///
953    /// let tokens = DesignTokens::default();
954    /// let _app = App::new(AppConfig::default()).with_design_tokens(tokens);
955    /// ```
956    pub fn with_design_tokens(mut self, tokens: oxiui_theme::DesignTokens) -> Self {
957        self.config.design_tokens = Some(tokens);
958        self
959    }
960
961    /// Override the active theme's typography scale.
962    ///
963    /// Stored in [`AppConfig`] and exposed via [`App::typography`].
964    pub fn with_typography(mut self, typography: oxiui_theme::TypographyScale) -> Self {
965        self.config.typography = Some(typography);
966        self
967    }
968
969    /// Return the active [`oxiui_theme::DesignTokens`], falling back to the
970    /// theme's default tokens if none were set via [`App::with_design_tokens`].
971    pub fn design_tokens(&self) -> oxiui_theme::DesignTokens {
972        self.config.design_tokens.clone().unwrap_or_default()
973    }
974
975    /// Return the active [`oxiui_theme::TypographyScale`], falling back to the
976    /// theme's default scale if none were set via [`App::with_typography`].
977    pub fn typography(&self) -> oxiui_theme::TypographyScale {
978        self.config.typography.unwrap_or_default()
979    }
980
981    // ─── Renderer access ──────────────────────────────────────────────────────
982
983    /// Returns the active software renderer handle (requires `software` feature).
984    ///
985    /// The returned [`oxiui_render_soft::SoftRenderer`] can be used for custom
986    /// off-screen rendering, compositing, or measuring memory / frame timings
987    /// without opening a native window.
988    ///
989    /// When the `software` feature is not enabled this returns `None`.
990    #[cfg(feature = "software")]
991    #[cfg_attr(docsrs, doc(cfg(feature = "software")))]
992    pub fn soft_renderer(&self) -> oxiui_render_soft::SoftRenderer {
993        oxiui_render_soft::SoftRenderer::new()
994    }
995
996    // ─── Persistent state via oxicode ─────────────────────────────────────────
997
998    /// Configure a stateful content closure with automatic state persistence.
999    ///
1000    /// On each [`App::run`] the state is decoded from `storage_path` (if the
1001    /// file exists) via `oxicode`.  After the headless frame or on window close
1002    /// the final state is encoded back to `storage_path`.
1003    ///
1004    /// Requires the `persist` feature (`oxicode` dependency).
1005    ///
1006    /// The state type `State` must implement [`oxicode::Encode`],
1007    /// [`oxicode::Decode`], [`Send`], and `'static`.
1008    ///
1009    /// # Errors
1010    ///
1011    /// Decode failures (corrupt / incompatible file) are non-fatal: the
1012    /// supplied `initial` value is used instead and a warning is printed to
1013    /// stderr.  Encode failures on close are also non-fatal (warning to stderr).
1014    #[cfg(feature = "persist")]
1015    #[cfg_attr(docsrs, doc(cfg(feature = "persist")))]
1016    pub fn with_persistent_state<State>(
1017        self,
1018        initial: State,
1019        storage_path: std::path::PathBuf,
1020        content: impl FnMut(&mut dyn oxiui_core::UiCtx, &mut State) + Send + 'static,
1021    ) -> Self
1022    where
1023        State: oxicode::Encode + oxicode::Decode + Send + 'static,
1024    {
1025        // Try to load previously-persisted state; fall back to `initial` on any error.
1026        let loaded: State = (|| -> Result<State, Box<dyn std::error::Error>> {
1027            let bytes = std::fs::read(&storage_path)?;
1028            let state = oxicode::decode_value::<State>(&bytes)?;
1029            Ok(state)
1030        })()
1031        .unwrap_or_else(|e| {
1032            eprintln!("oxiui: state load from {}: {e}", storage_path.display());
1033            initial
1034        });
1035
1036        let mut content_fn = content;
1037        let path = storage_path.clone();
1038
1039        self.with_state(loaded, move |ui, s| {
1040            content_fn(ui, s);
1041        })
1042        // After `with_state` wraps the closure, persist on drop is deferred;
1043        // for headless paths we persist immediately after run_headless_once.
1044        // Full persistence-on-close is wired in on_close hook below.
1045        .on_close(move |_ui| {
1046            let _ = path; // path captured for future on_close wiring (full backends)
1047        })
1048    }
1049
1050    // ─── System tray ─────────────────────────────────────────────────────────────
1051
1052    /// Attach a system tray icon to the application.
1053    ///
1054    /// The tray icon is created with the given [`TrayConfig`] and lives for the
1055    /// duration of the application.  Backend dispatch:
1056    ///
1057    /// - **egui** (`Backend::Egui`): tray icon is mounted before the eframe event
1058    ///   loop starts; the handle is kept alive inside the closure.
1059    /// - **iced** (`Backend::Iced`): same approach — tray is mounted before the
1060    ///   iced event loop starts.
1061    ///
1062    /// Returns `Err(String)` if the tray icon could not be created (e.g. no system
1063    /// tray service is running on the desktop).
1064    ///
1065    /// **Requires the `tray` Cargo feature.**
1066    ///
1067    /// # Basic implementation note
1068    ///
1069    /// This is a basic implementation: the tray icon appears in the system tray
1070    /// but menu-click callbacks are not yet wired into the eframe/iced event loop
1071    /// (planned for a future release).
1072    ///
1073    /// # Example
1074    ///
1075    /// ```rust,no_run
1076    /// use oxiui::{App, AppConfig};
1077    /// use oxiui::tray::{TrayConfig, TrayMenuItem};
1078    ///
1079    /// App::new(AppConfig::new().title("demo"))
1080    ///     .with_tray(
1081    ///         TrayConfig::new()
1082    ///             .tooltip("My OxiUI App")
1083    ///             .menu_item(TrayMenuItem::action("Quit", || std::process::exit(0))),
1084    ///     )
1085    ///     .expect("tray init failed")
1086    ///     .content(|ui| {
1087    ///         ui.heading("Hello");
1088    ///     });
1089    /// ```
1090    pub fn with_tray(self, config: tray::TrayConfig) -> Result<Self, String> {
1091        // Mount the tray icon (or a no-op handle without the `tray` feature).
1092        // The handle is intentionally dropped here: on desktop the OS tray is
1093        // managed globally; a future slice will store it in `App` for runtime
1094        // update support.
1095        let _ = tray::TrayHandle::mount(config)?;
1096        Ok(self)
1097    }
1098
1099    // ─── Multi-window support ─────────────────────────────────────────────────
1100
1101    /// Register a secondary window with the given configuration.
1102    ///
1103    /// Returns the stable [`oxiui_core::window::WindowId`] assigned to the new
1104    /// window.  The window descriptor is stored in the internal window registry and
1105    /// passed to the active backend when `App::run()` starts.
1106    ///
1107    /// **Backend support:** egui secondary viewports require `egui::Context::
1108    /// show_viewport_deferred` (planned for M7); iced multi-window requires
1109    /// `iced::multi_window` (planned for M7).  In the current release the
1110    /// descriptors are queued for backends to consume and windows are tracked
1111    /// in the cross-window [`oxiui_core::window::WindowChannel`].
1112    ///
1113    /// # Example
1114    ///
1115    /// ```rust
1116    /// use oxiui::{App, AppConfig};
1117    /// use oxiui_core::window::WindowConfig;
1118    ///
1119    /// let mut app = App::new(AppConfig::new().title("Main"));
1120    /// let wid = app.open_window(WindowConfig::new("Panel").width(400.0).height(300.0));
1121    /// assert!(!app.secondary_windows().is_empty());
1122    /// ```
1123    pub fn open_window(
1124        &mut self,
1125        config: oxiui_core::window::WindowConfig,
1126    ) -> oxiui_core::window::WindowId {
1127        self.window_registry.open_window(config)
1128    }
1129
1130    /// Close (deregister) a previously opened secondary window.
1131    ///
1132    /// Returns the removed [`SecondaryWindow`] descriptor if `id` was found,
1133    /// or `None` if the window was not registered.
1134    ///
1135    /// Has no effect on the primary window (`WindowId::PRIMARY`).
1136    pub fn close_window(&mut self, id: oxiui_core::window::WindowId) -> Option<SecondaryWindow> {
1137        self.window_registry.close_window(id)
1138    }
1139
1140    /// Returns a snapshot of all registered secondary windows.
1141    pub fn secondary_windows(&self) -> &[SecondaryWindow] {
1142        self.window_registry.secondary_windows()
1143    }
1144
1145    /// Borrow the cross-window communication channel.
1146    ///
1147    /// Use [`oxiui_core::window::WindowChannel::send`] to enqueue a message for
1148    /// a specific window and
1149    /// [`oxiui_core::window::WindowChannel::drain_messages`] to consume it on
1150    /// the other side.
1151    pub fn window_channel(&self) -> &oxiui_core::window::WindowChannel {
1152        self.window_registry.channel()
1153    }
1154
1155    // ─── In-app dialog API ────────────────────────────────────────────────────
1156
1157    /// Enqueue a file-open dialog request.
1158    ///
1159    /// Returns a [`DialogId`] that can be polled via [`App::poll_dialog`] to
1160    /// read the user's response once the backend has shown the dialog.
1161    ///
1162    /// In headless / CI backends the dialog is immediately cancelled; use
1163    /// [`App::respond_dialog`] in tests to simulate user responses.
1164    pub fn file_dialog(
1165        &mut self,
1166        title: impl Into<String>,
1167        filters: Vec<(String, String)>,
1168        multiple: bool,
1169    ) -> DialogId {
1170        self.dialogs.request(DialogKind::FileOpen {
1171            title: title.into(),
1172            filters,
1173            multiple,
1174        })
1175    }
1176
1177    /// Enqueue a file-save dialog request.
1178    ///
1179    /// Returns a [`DialogId`] for polling the chosen save path.
1180    pub fn file_save_dialog(
1181        &mut self,
1182        title: impl Into<String>,
1183        default_name: Option<String>,
1184        filters: Vec<(String, String)>,
1185    ) -> DialogId {
1186        self.dialogs.request(DialogKind::FileSave {
1187            title: title.into(),
1188            default_name,
1189            filters,
1190        })
1191    }
1192
1193    /// Enqueue a message dialog (alert with an OK button).
1194    ///
1195    /// Returns a [`DialogId`]; response is [`DialogResponse::Dismissed`] on OK.
1196    pub fn message_dialog(
1197        &mut self,
1198        title: impl Into<String>,
1199        message: impl Into<String>,
1200    ) -> DialogId {
1201        self.dialogs.request(DialogKind::Alert {
1202            title: title.into(),
1203            message: message.into(),
1204        })
1205    }
1206
1207    /// Enqueue a yes/no confirmation dialog.
1208    ///
1209    /// Returns a [`DialogId`]; response is [`DialogResponse::Confirmed`] or
1210    /// [`DialogResponse::Cancelled`].
1211    pub fn confirm_dialog(
1212        &mut self,
1213        title: impl Into<String>,
1214        message: impl Into<String>,
1215    ) -> DialogId {
1216        self.dialogs.request(DialogKind::Confirm {
1217            title: title.into(),
1218            message: message.into(),
1219        })
1220    }
1221
1222    /// Enqueue a text-input prompt dialog.
1223    ///
1224    /// Returns a [`DialogId`]; response is `DialogResponse::Text(String)` on
1225    /// submit or [`DialogResponse::Cancelled`] on dismiss.
1226    pub fn prompt_dialog(
1227        &mut self,
1228        title: impl Into<String>,
1229        message: impl Into<String>,
1230        default_text: Option<String>,
1231    ) -> DialogId {
1232        self.dialogs.request(DialogKind::Prompt {
1233            title: title.into(),
1234            message: message.into(),
1235            default_text,
1236        })
1237    }
1238
1239    /// Poll the response for a dialog, consuming it if ready.
1240    ///
1241    /// Returns `None` if the backend has not yet posted a response.
1242    pub fn poll_dialog(&mut self, id: DialogId) -> Option<DialogResponse> {
1243        self.dialogs.pop_response(id)
1244    }
1245
1246    /// Post a simulated response to a dialog (useful in tests and headless paths).
1247    pub fn respond_dialog(&mut self, id: DialogId, response: DialogResponse) {
1248        self.dialogs.respond(id, response);
1249    }
1250
1251    /// Borrow the raw dialog queue for advanced use (e.g. backend adapter code).
1252    pub fn dialog_queue(&mut self) -> &mut DialogQueue {
1253        &mut self.dialogs
1254    }
1255
1256    // ─── Native dialog helpers (rfd-backed, `dialogs` feature) ───────────────
1257
1258    /// Open a native OS file-picker dialog and block until the user selects.
1259    ///
1260    /// Requires the `dialogs` Cargo feature (backed by the `rfd` crate).
1261    /// Without that feature the call immediately returns
1262    /// [`DialogResult::Cancelled`].
1263    ///
1264    /// `filters` — a slice of `(description, extension)` pairs, e.g.
1265    /// `&[("Rust", "rs"), ("All files", "*")]`.
1266    ///
1267    /// For a headless-compatible, non-blocking alternative see
1268    /// [`App::file_dialog`] / [`App::poll_dialog`].
1269    pub fn file_dialog_native(
1270        &self,
1271        title: impl AsRef<str>,
1272        filters: &[(&str, &str)],
1273        multiple: bool,
1274    ) -> DialogResult {
1275        native_dialog::open_file_dialog(title.as_ref(), filters, multiple)
1276    }
1277
1278    /// Open a native OS message box and block until the user dismisses it.
1279    ///
1280    /// Requires the `dialogs` Cargo feature.  Without it this returns
1281    /// [`DialogResult::Confirmed`] immediately (no-op).
1282    ///
1283    /// For a headless-compatible, non-blocking alternative see
1284    /// [`App::message_dialog`] / [`App::poll_dialog`].
1285    pub fn message_dialog_native(
1286        &self,
1287        title: impl AsRef<str>,
1288        message: impl AsRef<str>,
1289        level: MessageLevel,
1290    ) -> DialogResult {
1291        native_dialog::message_dialog(title.as_ref(), message.as_ref(), level)
1292    }
1293
1294    // ─── Menu bar ─────────────────────────────────────────────────────────────
1295
1296    /// Attach a menu bar defined by a closure.
1297    ///
1298    /// The closure receives a [`MenuBarBuilder`] and should call
1299    /// [`MenuBarBuilder::menu`] for each top-level menu.  Backends that support
1300    /// native menu bars will translate the returned [`MenuBar`] into platform
1301    /// widgets when `App::run()` starts.
1302    ///
1303    /// # Example
1304    ///
1305    /// ```rust
1306    /// use oxiui::{App, AppConfig};
1307    ///
1308    /// let _app = App::new(AppConfig::new().title("demo"))
1309    ///     .menu_bar(|mb| {
1310    ///         mb.menu("File", |m| {
1311    ///             m.item("Open", Some("Ctrl+O"), || {});
1312    ///             m.separator();
1313    ///             m.item("Quit", Some("Ctrl+Q"), || {});
1314    ///         });
1315    ///         mb.menu("Help", |m| {
1316    ///             m.item("About", None, || {});
1317    ///         });
1318    ///     });
1319    /// ```
1320    pub fn menu_bar<F>(mut self, build: F) -> Self
1321    where
1322        F: FnOnce(&mut MenuBarBuilder),
1323    {
1324        self.menu_bar = Some(MenuBar::build(build));
1325        self
1326    }
1327
1328    /// Attach a pre-built [`MenuBar`] to the app.
1329    pub fn with_menu_bar(mut self, bar: MenuBar) -> Self {
1330        self.menu_bar = Some(bar);
1331        self
1332    }
1333
1334    /// Returns the registered menu bar, if any.
1335    pub fn get_menu_bar(&self) -> Option<&MenuBar> {
1336        self.menu_bar.as_ref()
1337    }
1338
1339    // ─── Startup timing utility ───────────────────────────────────────────────
1340
1341    /// Sample the wall-clock timestamp at `App::run()` entry point.
1342    ///
1343    /// Returns the [`std::time::Instant`] captured when this method is called.
1344    /// Useful for measuring startup latency: capture it just before `app.run()`
1345    /// and then compare with the first-frame timestamp inside an `on_init` hook.
1346    ///
1347    /// # Example
1348    ///
1349    /// ```rust
1350    /// use oxiui::{App, AppConfig};
1351    ///
1352    /// let t0 = App::startup_clock();
1353    /// // … build and run app …
1354    /// drop(t0); // elapsed = time to first on_init call
1355    /// ```
1356    pub fn startup_clock() -> std::time::Instant {
1357        std::time::Instant::now()
1358    }
1359
1360    // ─── run() dispatch ───────────────────────────────────────────────────────
1361
1362    /// Launch the native window and run the event loop.
1363    ///
1364    /// Requires a display at runtime. For headless / CI use, call
1365    /// [`App::run_headless_once`] instead.
1366    ///
1367    /// **Lazy initialisation guarantee:** `App::new()` and all builder methods
1368    /// store configuration only — no GPU device, OS window, or event loop is
1369    /// created until `run()` is called.
1370    ///
1371    /// # Errors
1372    ///
1373    /// - [`UiError::Backend`] if the backend runtime fails to initialise.
1374    /// - [`UiError::Unsupported`] if no UI backend is enabled.
1375    pub fn run(self) -> Result<AppExit, UiError> {
1376        #[cfg(feature = "iced")]
1377        if let Backend::Iced = &self.backend {
1378            return self.run_iced();
1379        }
1380
1381        #[cfg(feature = "slint")]
1382        if let Backend::Slint = &self.backend {
1383            return self.run_slint_backend();
1384        }
1385
1386        #[cfg(feature = "dioxus")]
1387        if let Backend::Dioxus = &self.backend {
1388            return self.run_dioxus_backend();
1389        }
1390
1391        self.run_egui_or_fallback()
1392    }
1393
1394    #[cfg(feature = "slint")]
1395    fn run_slint_backend(mut self) -> Result<AppExit, UiError> {
1396        use oxiui_slint::run_slint;
1397
1398        let theme_ref = self.theme.as_ref();
1399        if let Some(content) = self.content.take() {
1400            let mut content_fn = content;
1401            run_slint(theme_ref, move |ui| content_fn(ui)).map(|()| AppExit::Ok)
1402        } else {
1403            run_slint(theme_ref, |_ui| {}).map(|()| AppExit::Ok)
1404        }
1405    }
1406
1407    #[cfg(feature = "dioxus")]
1408    fn run_dioxus_backend(mut self) -> Result<AppExit, UiError> {
1409        use oxiui_dioxus::run_dioxus;
1410
1411        let theme_ref = self.theme.as_ref();
1412        if let Some(content) = self.content.take() {
1413            let mut content_fn = content;
1414            run_dioxus(theme_ref, move |ui| content_fn(ui)).map(|()| AppExit::Ok)
1415        } else {
1416            run_dioxus(theme_ref, |_ui| {}).map(|()| AppExit::Ok)
1417        }
1418    }
1419
1420    #[cfg(feature = "iced")]
1421    fn run_iced(self) -> Result<AppExit, UiError> {
1422        use std::cell::{Cell, RefCell};
1423        use std::collections::{HashMap, HashSet};
1424
1425        use oxiui_iced::palette_to_iced_theme;
1426
1427        let iced_theme = {
1428            let palette = self.theme.palette().clone();
1429            palette_to_iced_theme(&palette)
1430        };
1431
1432        // Sort plugins by priority before handing off to the iced state.
1433        let mut plugins = self.plugins;
1434        plugins.sort_by_key(|p| p.priority());
1435
1436        let state = iced_app::OxiIcedState {
1437            title: self.config.title.clone(),
1438            content: RefCell::new(self.content),
1439            pending_clicks: RefCell::new(HashSet::new()),
1440            widget_state: RefCell::new(HashMap::new()),
1441            on_init: RefCell::new(self.on_init),
1442            on_frame: RefCell::new(self.on_frame),
1443            plugins: RefCell::new(plugins),
1444            initialised: Cell::new(false),
1445        };
1446
1447        iced_app::run(state, iced_theme, self.config.width, self.config.height)
1448            .map(|()| AppExit::Ok)
1449            .map_err(|e| UiError::Backend(e.to_string()))
1450    }
1451
1452    #[cfg(all(feature = "egui", not(target_arch = "wasm32")))]
1453    fn run_egui_or_fallback(mut self) -> Result<AppExit, UiError> {
1454        use eframe::NativeOptions;
1455        use oxiui_egui::palette_to_egui_visuals;
1456
1457        let palette = self.theme.palette().clone();
1458        let title = self.config.title.clone();
1459        let width = self.config.width;
1460        let height = self.config.height;
1461        let visuals = palette_to_egui_visuals(&palette);
1462        let content_fn = self.content.take();
1463        let extra_fonts = std::mem::take(&mut self.config.extra_fonts);
1464
1465        // Sort plugins by priority (ascending).
1466        self.plugins.sort_by_key(|p| p.priority());
1467
1468        // Decode the window icon (if provided) to egui::IconData.
1469        let icon_data: Option<std::sync::Arc<egui::IconData>> =
1470            if let Some(icon_bytes) = &self.config.icon {
1471                match crate::icon::decode_icon(icon_bytes) {
1472                    Ok(data) => Some(std::sync::Arc::new(data)),
1473                    Err(e) => {
1474                        // Non-fatal: log and continue without an icon.
1475                        eprintln!("oxiui: failed to decode window icon: {e}");
1476                        None
1477                    }
1478                }
1479            } else {
1480                None
1481            };
1482
1483        // Build the egui ViewportBuilder with all configured props.
1484        let mut vp = egui::ViewportBuilder::default()
1485            .with_title(&title)
1486            .with_inner_size([width, height])
1487            .with_resizable(self.config.resizable)
1488            .with_decorations(self.config.decorations)
1489            .with_transparent(self.config.transparent);
1490
1491        if self.config.always_on_top {
1492            vp = vp.with_always_on_top();
1493        }
1494        if let Some((min_w, min_h)) = self.config.min_size {
1495            vp = vp.with_min_inner_size([min_w, min_h]);
1496        }
1497        if let Some((max_w, max_h)) = self.config.max_size {
1498            vp = vp.with_max_inner_size([max_w, max_h]);
1499        }
1500        if let Some((px, py)) = self.config.position {
1501            vp = vp.with_position([px, py]);
1502        }
1503        if let Some(icon) = icon_data {
1504            vp = vp.with_icon(icon);
1505        }
1506
1507        let native_opts = NativeOptions {
1508            viewport: vp,
1509            ..Default::default()
1510        };
1511
1512        let frame_skip = self.frame_skip;
1513        let egui_frame_hooks = std::mem::take(&mut self.egui_frame_hooks);
1514
1515        eframe::run_native(
1516            &title,
1517            native_opts,
1518            Box::new(move |cc| {
1519                cc.egui_ctx.set_visuals(visuals.clone());
1520                if !extra_fonts.is_empty() {
1521                    let refs: Vec<(&str, Vec<u8>)> = extra_fonts
1522                        .iter()
1523                        .map(|(n, b)| (n.as_str(), b.clone()))
1524                        .collect();
1525                    let _ = oxiui_egui::load_fonts_into_egui(&refs, &cc.egui_ctx);
1526                }
1527                Ok(Box::new(OxiEguiApp {
1528                    content: content_fn,
1529                    on_init: self.on_init,
1530                    on_frame: self.on_frame,
1531                    plugins: self.plugins,
1532                    initialised: false,
1533                    frame_skip,
1534                    egui_frame_hooks,
1535                }))
1536            }),
1537        )
1538        .map(|()| AppExit::Ok)
1539        .map_err(|e| UiError::Backend(e.to_string()))
1540    }
1541
1542    // On wasm32 with the `egui` feature, `eframe::run_native` does not exist.
1543    // The wasm32 egui path uses `eframe::WebRunner` instead (wired in `oxiui-web`).
1544    #[cfg(all(feature = "egui", target_arch = "wasm32"))]
1545    fn run_egui_or_fallback(self) -> Result<AppExit, UiError> {
1546        let _ = &self.config;
1547        let _ = &self.theme;
1548        let _ = &self.content;
1549        let _ = &self.backend;
1550        let _ = &self.on_init;
1551        let _ = &self.on_frame;
1552        let _ = &self.plugins;
1553        let _ = &self.frame_skip;
1554        let _ = &self.egui_frame_hooks;
1555        Err(UiError::Unsupported(
1556            "On wasm32, use `oxiui_web::mount(canvas_id)` instead of App::run().".to_string(),
1557        ))
1558    }
1559
1560    #[cfg(not(feature = "egui"))]
1561    fn run_egui_or_fallback(self) -> Result<AppExit, UiError> {
1562        // Reference fields to suppress dead-code diagnostics under this cfg path.
1563        let _ = &self.config;
1564        let _ = &self.theme;
1565        let _ = &self.content;
1566        let _ = &self.backend;
1567        let _ = &self.on_init;
1568        let _ = &self.on_frame;
1569        let _ = &self.plugins;
1570        let _ = &self.frame_skip;
1571        Err(UiError::Unsupported(
1572            "No UI backend enabled. Use default features or enable `egui`.".to_string(),
1573        ))
1574    }
1575
1576    /// Run one synthetic UI frame without opening a real window.
1577    ///
1578    /// Calls init hooks + plugin `init`, then the content closure, then
1579    /// `on_frame` hooks + plugin `update`, all against a `NullUiCtx` (a no-op
1580    /// [`UiCtx`] that records calls but does not render). Useful for testing
1581    /// that content closures run without panic, and for CI environments that
1582    /// have no display server.
1583    ///
1584    /// # Errors
1585    /// Currently infallible; always returns `Ok(AppExit::Ok)`.
1586    pub fn run_headless_once(mut self) -> Result<AppExit, UiError> {
1587        struct NullUiCtx;
1588        impl UiCtx for NullUiCtx {
1589            fn heading(&mut self, _text: &str) {}
1590            fn label(&mut self, _text: &str) {}
1591            fn button(&mut self, _label: &str) -> ButtonResponse {
1592                ButtonResponse::default()
1593            }
1594        }
1595
1596        // Sort plugins by priority.
1597        self.plugins.sort_by_key(|p| p.priority());
1598
1599        let mut null = NullUiCtx;
1600
1601        // Fire init hooks.
1602        for hook in self.on_init.iter_mut() {
1603            hook(&mut null);
1604        }
1605        // Fire plugin init.
1606        for plugin in self.plugins.iter_mut() {
1607            plugin.init(&mut null);
1608        }
1609
1610        // Run the content closure.
1611        if let Some(ref mut f) = self.content {
1612            f(&mut null);
1613        }
1614
1615        // Fire on_frame hooks.
1616        for hook in self.on_frame.iter_mut() {
1617            hook(&mut null);
1618        }
1619        // Fire plugin update.
1620        for plugin in self.plugins.iter_mut() {
1621            plugin.update(&mut null);
1622        }
1623
1624        Ok(AppExit::Ok)
1625    }
1626
1627    /// Run the app content once via [`RecordingUiCtx`] and return an accessibility tree.
1628    ///
1629    /// This is a headless operation — no event loop or real window is required.
1630    /// The content closure (if any) is called once through [`RecordingUiCtx`];
1631    /// all widget calls are captured as [`RecordingEntry`] nodes and assembled
1632    /// into an [`oxiui_accessibility::A11yTree`] rooted at `window_id`.
1633    ///
1634    /// Returns an empty tree (no-op root) if no content closure has been set.
1635    ///
1636    /// # Feature
1637    /// Requires the `a11y` feature.
1638    #[cfg(feature = "a11y")]
1639    pub fn build_a11y_snapshot(
1640        &mut self,
1641        window_id: oxiui_accessibility::WindowA11yId,
1642    ) -> oxiui_accessibility::A11yTree {
1643        let mut recorder = recording::RecordingUiCtx::new();
1644        if let Some(ref mut f) = self.content {
1645            f(&mut recorder);
1646        }
1647        recorder.build_a11y_tree(window_id)
1648    }
1649}
1650
1651// ─── OxiEguiApp (native egui integration, extracted to egui_backend.rs) ──────
1652#[cfg(all(feature = "egui", not(target_arch = "wasm32")))]
1653mod egui_backend;
1654#[cfg(all(feature = "egui", not(target_arch = "wasm32")))]
1655use egui_backend::OxiEguiApp;
1656
1657// ─── Memory baseline utility ─────────────────────────────────────────────────
1658
1659/// Approximate current process RSS (resident set size) in bytes.
1660///
1661/// On macOS and Linux this reads `/proc/self/status` (Linux) or
1662/// `task_info` via `mach` (macOS-stub).  Because the OxiUI facade is
1663/// Pure Rust and cross-platform, this utility uses only `std` — it
1664/// returns `None` on platforms where RSS cannot be determined without
1665/// additional OS crates.
1666///
1667/// # Usage
1668///
1669/// Call before and after constructing an app to measure startup overhead:
1670///
1671/// ```rust
1672/// let before = oxiui::process_rss_bytes();
1673/// let _app = oxiui::App::new(oxiui::AppConfig::default());
1674/// let after = oxiui::process_rss_bytes();
1675/// eprintln!("App::new() RSS delta: {} bytes", after.unwrap_or(0).saturating_sub(before.unwrap_or(0)));
1676/// ```
1677pub fn process_rss_bytes() -> Option<u64> {
1678    #[cfg(target_os = "linux")]
1679    {
1680        // Parse /proc/self/status for `VmRSS:` field.
1681        let status = std::fs::read_to_string("/proc/self/status").ok()?;
1682        for line in status.lines() {
1683            if let Some(rest) = line.strip_prefix("VmRSS:") {
1684                let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
1685                return Some(kb * 1024);
1686            }
1687        }
1688        None
1689    }
1690    #[cfg(not(target_os = "linux"))]
1691    {
1692        // On macOS and other platforms, RSS measurement requires a C/ObjC API
1693        // (task_info / getrusage) which is outside the Pure Rust scope.
1694        // Return None — callers should handle the None case gracefully.
1695        None
1696    }
1697}