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}