#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
pub use oxiui_core::{ButtonResponse, Color, FontSpec, Palette, Theme, UiCtx, UiError};
pub mod runner;
#[cfg(feature = "egui")]
#[cfg_attr(docsrs, doc(cfg(feature = "egui")))]
pub use runner::EguiRunner;
#[cfg(feature = "iced")]
#[cfg_attr(docsrs, doc(cfg(feature = "iced")))]
pub use runner::IcedRunner;
pub use runner::{BackendRunner, LifecycleConfig};
#[cfg(feature = "egui")]
pub(crate) mod icon;
pub mod theme_picker;
pub use theme_picker::{by_name as theme_by_name, theme_picker, BUILTIN_THEMES};
pub mod theme {
pub use oxiui_theme::{cooljapan_default, dark, light};
}
#[cfg(feature = "table")]
#[cfg_attr(docsrs, doc(cfg(feature = "table")))]
pub mod table {
pub use oxiui_table::*;
}
#[cfg(feature = "a11y")]
#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
pub mod accessibility {
pub use oxiui_accessibility::{A11yNode, A11yTree, WidgetRole};
}
#[cfg(feature = "a11y")]
#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
pub mod recording;
#[cfg(feature = "a11y")]
#[cfg_attr(docsrs, doc(cfg(feature = "a11y")))]
pub use recording::{RecordingEntry, RecordingUiCtx};
#[cfg(feature = "web")]
#[cfg_attr(docsrs, doc(cfg(feature = "web")))]
pub mod web {
pub use oxiui_web::mount;
}
#[cfg(feature = "software")]
#[cfg_attr(docsrs, doc(cfg(feature = "software")))]
pub mod render {
pub use oxiui_render_soft::{
render_headless_once, render_headless_scene, Framebuffer, RgbaBuffer,
};
}
pub mod text {
pub use oxiui_core::{FontFeature, FontSpec, FontStyle};
}
pub mod solver {
pub use oxiui_core::{
Constraint, Expression, RelOp, Solver, SolverError, Strength, Term, Variable,
};
}
pub mod prelude {
pub use crate::{App, AppConfig, AppExit, Backend, HotkeyConflict, Notification, Plugin};
pub use oxiui_core::{AlignContent, FlexWrap, RichTextSpan};
pub use oxiui_core::{ButtonResponse, Color, UiCtx, UiError};
pub use oxiui_core::{Computed, ReactiveError, ReactiveRuntime, Signal};
pub use oxiui_core::{Point, Rect, Size};
pub use oxiui_theme::CooljapanTheme;
}
pub mod core {
pub use oxiui_core::*;
}
pub mod reactive {
pub use oxiui_core::{Computed, ReactiveError, ReactiveRuntime, Signal};
}
#[derive(Clone, Debug, Default)]
pub enum Backend {
#[default]
Egui,
#[cfg(feature = "iced")]
Iced,
#[cfg(feature = "slint")]
Slint,
#[cfg(feature = "dioxus")]
Dioxus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppExit {
Ok,
Error(String),
RequestedByUser,
Programmatic(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HotkeyConflict {
pub message: String,
}
impl std::fmt::Display for HotkeyConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "HotkeyConflict: {}", self.message)
}
}
impl std::error::Error for HotkeyConflict {}
#[derive(Debug, Clone)]
pub struct AppConfig {
pub title: String,
pub width: f32,
pub height: f32,
pub resizable: bool,
pub min_size: Option<(f32, f32)>,
pub max_size: Option<(f32, f32)>,
pub decorations: bool,
pub transparent: bool,
pub always_on_top: bool,
pub icon: Option<Vec<u8>>,
pub position: Option<(f32, f32)>,
pub extra_fonts: Vec<(String, Vec<u8>)>,
}
impl Default for AppConfig {
fn default() -> Self {
Self::new()
}
}
impl AppConfig {
pub fn new() -> Self {
Self {
title: String::new(),
width: 800.0,
height: 600.0,
resizable: true,
min_size: None,
max_size: None,
decorations: true,
transparent: false,
always_on_top: false,
icon: None,
position: None,
extra_fonts: Vec::new(),
}
}
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = t.into();
self
}
pub fn size(mut self, w: f32, h: f32) -> Self {
self.width = w;
self.height = h;
self
}
pub fn resizable(mut self, r: bool) -> Self {
self.resizable = r;
self
}
pub fn min_size(mut self, w: f32, h: f32) -> Self {
self.min_size = Some((w, h));
self
}
pub fn max_size(mut self, w: f32, h: f32) -> Self {
self.max_size = Some((w, h));
self
}
pub fn decorations(mut self, d: bool) -> Self {
self.decorations = d;
self
}
pub fn transparent(mut self, t: bool) -> Self {
self.transparent = t;
self
}
pub fn always_on_top(mut self, a: bool) -> Self {
self.always_on_top = a;
self
}
pub fn icon(mut self, bytes: Vec<u8>) -> Self {
self.icon = Some(bytes);
self
}
pub fn position(mut self, x: f32, y: f32) -> Self {
self.position = Some((x, y));
self
}
}
type ContentFn = Box<dyn FnMut(&mut dyn oxiui_core::UiCtx) + Send>;
type HookFn = Box<dyn FnMut(&mut dyn oxiui_core::UiCtx) + Send + Sync>;
#[cfg(feature = "egui")]
type EguiFrameHook = Box<dyn FnMut(&egui::Context) + Send>;
pub trait Plugin: Send + Sync {
fn init(&mut self, ctx: &mut dyn UiCtx);
fn update(&mut self, ctx: &mut dyn UiCtx);
fn priority(&self) -> i32 {
0
}
}
use oxiui_core::events::{Key, Modifiers};
pub struct HotkeyBinding {
pub id: String,
pub modifiers: Modifiers,
pub key: Key,
pub action: Box<dyn Fn() + Send + Sync>,
}
pub struct HotkeyRegistry {
bindings: Vec<HotkeyBinding>,
}
impl HotkeyRegistry {
pub fn new() -> Self {
Self {
bindings: Vec::new(),
}
}
pub fn register(
&mut self,
id: impl Into<String>,
mods: Modifiers,
key: Key,
action: impl Fn() + Send + Sync + 'static,
) -> Result<(), String> {
if self.conflict_check(mods, key.clone()) {
return Err(format!("hotkey conflict: {mods:?}+{key:?}"));
}
self.bindings.push(HotkeyBinding {
id: id.into(),
modifiers: mods,
key,
action: Box::new(action),
});
Ok(())
}
pub fn conflict_check(&self, mods: Modifiers, key: Key) -> bool {
self.bindings
.iter()
.any(|b| b.modifiers == mods && b.key == key)
}
pub fn len(&self) -> usize {
self.bindings.len()
}
pub fn is_empty(&self) -> bool {
self.bindings.is_empty()
}
}
impl Default for HotkeyRegistry {
fn default() -> Self {
Self::new()
}
}
pub struct Command {
pub id: String,
pub label: String,
pub shortcut: Option<String>,
pub action: Box<dyn Fn() + Send + Sync>,
}
pub struct CommandPalette {
commands: Vec<Command>,
}
impl CommandPalette {
pub fn new() -> Self {
Self {
commands: Vec::new(),
}
}
pub fn register(
&mut self,
id: impl Into<String>,
label: impl Into<String>,
action: impl Fn() + Send + Sync + 'static,
) {
self.commands.push(Command {
id: id.into(),
label: label.into(),
shortcut: None,
action: Box::new(action),
});
}
pub fn register_with_shortcut(
&mut self,
id: impl Into<String>,
label: impl Into<String>,
shortcut: Option<String>,
action: impl Fn() + Send + Sync + 'static,
) {
self.commands.push(Command {
id: id.into(),
label: label.into(),
shortcut,
action: Box::new(action),
});
}
pub fn search(&self, query: &str) -> Vec<&Command> {
let query_lc = query.to_lowercase();
self.commands
.iter()
.filter(|cmd| {
let label_lc = cmd.label.to_lowercase();
let mut q_iter = query_lc.chars();
let mut current = q_iter.next();
for ch in label_lc.chars() {
if current == Some(ch) {
current = q_iter.next();
}
if current.is_none() {
return true;
}
}
current.is_none()
})
.collect()
}
pub fn len(&self) -> usize {
self.commands.len()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
impl Default for CommandPalette {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct Notification {
pub title: String,
pub body: String,
pub duration_ms: u64,
pub urgency: u8,
pub created_at: std::time::Instant,
}
pub struct NotificationQueue {
pending: std::collections::VecDeque<Notification>,
}
impl NotificationQueue {
pub fn new() -> Self {
Self {
pending: std::collections::VecDeque::new(),
}
}
pub fn push(&mut self, title: impl Into<String>, body: impl Into<String>, duration_ms: u64) {
self.pending.push_back(Notification {
title: title.into(),
body: body.into(),
duration_ms,
urgency: 1,
created_at: std::time::Instant::now(),
});
}
pub fn enqueue(&mut self, title: impl Into<String>, body: impl Into<String>, urgency: u8) {
let duration_ms = match urgency {
0 => 3_000,
2 => 10_000,
_ => 5_000,
};
self.pending.push_back(Notification {
title: title.into(),
body: body.into(),
duration_ms,
urgency,
created_at: std::time::Instant::now(),
});
}
pub fn pop_due(&mut self) -> Option<Notification> {
self.pending.pop_front()
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
pub fn len(&self) -> usize {
self.pending.len()
}
}
impl Default for NotificationQueue {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "iced")]
mod iced_app {
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use iced::Element;
use iced::Task;
use oxiui_iced::{apply_message, IcedConfig, IcedUiCtx, Message, WidgetState};
use crate::{ContentFn, HookFn, Plugin};
pub struct OxiIcedState {
pub title: String,
pub content: RefCell<Option<ContentFn>>,
pub pending_clicks: RefCell<HashSet<usize>>,
pub widget_state: RefCell<HashMap<usize, WidgetState>>,
pub on_init: RefCell<Vec<HookFn>>,
pub on_frame: RefCell<Vec<HookFn>>,
pub plugins: RefCell<Vec<Box<dyn Plugin>>>,
pub initialised: Cell<bool>,
}
impl OxiIcedState {
pub fn empty() -> Self {
Self {
title: String::new(),
content: RefCell::new(None),
pending_clicks: RefCell::new(HashSet::new()),
widget_state: RefCell::new(HashMap::new()),
on_init: RefCell::new(Vec::new()),
on_frame: RefCell::new(Vec::new()),
plugins: RefCell::new(Vec::new()),
initialised: Cell::new(false),
}
}
}
pub fn update(state: &mut OxiIcedState, msg: Message) -> Task<Message> {
let mut clicks = state.pending_clicks.borrow_mut();
let mut widget_state = state.widget_state.borrow_mut();
apply_message(&mut widget_state, &mut clicks, &msg);
Task::none()
}
pub fn view<'a>(state: &'a OxiIcedState) -> Element<'a, Message> {
let clicks = {
let mut guard = state.pending_clicks.borrow_mut();
std::mem::take(&mut *guard)
};
let widget_state = state.widget_state.borrow().clone();
let config = IcedConfig {
pending_clicks: clicks,
state: widget_state,
spacing: 8.0,
padding: 0.0,
title: state.title.clone(),
spec_capacity_hint: 0,
};
let mut ctx = IcedUiCtx::new(config);
if !state.initialised.get() {
state.initialised.set(true);
if let Ok(mut hooks) = state.on_init.try_borrow_mut() {
for hook in hooks.iter_mut() {
hook(&mut ctx);
}
}
if let Ok(mut plugins) = state.plugins.try_borrow_mut() {
for plugin in plugins.iter_mut() {
plugin.init(&mut ctx);
}
}
}
if let Ok(mut content_guard) = state.content.try_borrow_mut() {
if let Some(ref mut f) = *content_guard {
f(&mut ctx);
}
}
if let Ok(mut hooks) = state.on_frame.try_borrow_mut() {
for hook in hooks.iter_mut() {
hook(&mut ctx);
}
}
if let Ok(mut plugins) = state.plugins.try_borrow_mut() {
for plugin in plugins.iter_mut() {
plugin.update(&mut ctx);
}
}
let elem: Element<'static, Message> = ctx.into_iced_element();
elem
}
pub fn run(
state: OxiIcedState,
iced_theme: iced::Theme,
width: f32,
height: f32,
) -> iced::Result {
let boot_state = std::sync::Mutex::new(Some(state));
let boot = move || {
boot_state
.lock()
.ok()
.and_then(|mut g| g.take())
.unwrap_or_else(OxiIcedState::empty)
};
let title_fn = move |s: &OxiIcedState| s.title.clone();
let theme_fn = move |_: &OxiIcedState| iced_theme.clone();
let _ = width;
let _ = height;
iced::application(boot, update, view)
.title(title_fn)
.theme(theme_fn)
.run()
}
}
pub struct App {
config: AppConfig,
theme: Box<dyn oxiui_core::Theme>,
content: Option<ContentFn>,
backend: Backend,
on_init: Vec<HookFn>,
on_frame: Vec<HookFn>,
on_close: Vec<HookFn>,
on_resize: Vec<HookFn>,
on_focus: Vec<HookFn>,
plugins: Vec<Box<dyn Plugin>>,
hotkeys: HotkeyRegistry,
commands: CommandPalette,
notifications: NotificationQueue,
frame_skip: bool,
#[cfg(feature = "egui")]
egui_frame_hooks: Vec<EguiFrameHook>,
}
impl App {
pub fn new(config: AppConfig) -> Self {
Self {
config,
theme: oxiui_theme::cooljapan_default(),
content: None,
backend: Backend::default(),
on_init: Vec::new(),
on_frame: Vec::new(),
on_close: Vec::new(),
on_resize: Vec::new(),
on_focus: Vec::new(),
plugins: Vec::new(),
hotkeys: HotkeyRegistry::new(),
commands: CommandPalette::new(),
notifications: NotificationQueue::new(),
frame_skip: false,
#[cfg(feature = "egui")]
egui_frame_hooks: Vec::new(),
}
}
pub fn theme(mut self, theme: Box<dyn oxiui_core::Theme>) -> Self {
self.theme = theme;
self
}
pub fn content<F>(mut self, f: F) -> Self
where
F: FnMut(&mut dyn oxiui_core::UiCtx) + Send + 'static,
{
self.content = Some(Box::new(f));
self
}
pub fn backend(mut self, backend: Backend) -> Self {
self.backend = backend;
self
}
pub fn min_size(mut self, w: f32, h: f32) -> Self {
self.config.min_size = Some((w, h));
self
}
pub fn max_size(mut self, w: f32, h: f32) -> Self {
self.config.max_size = Some((w, h));
self
}
pub fn decorations(mut self, d: bool) -> Self {
self.config.decorations = d;
self
}
pub fn transparent(mut self, t: bool) -> Self {
self.config.transparent = t;
self
}
pub fn always_on_top(mut self, a: bool) -> Self {
self.config.always_on_top = a;
self
}
pub fn icon(mut self, bytes: Vec<u8>) -> Self {
self.config.icon = Some(bytes);
self
}
pub fn position(mut self, x: f32, y: f32) -> Self {
self.config.position = Some((x, y));
self
}
pub fn with_font(mut self, family_name: impl Into<String>, bytes: Vec<u8>) -> Self {
self.config.extra_fonts.push((family_name.into(), bytes));
self
}
pub fn with_state<State: Send + 'static>(
mut self,
state: State,
mut content: impl FnMut(&mut dyn oxiui_core::UiCtx, &mut State) + Send + 'static,
) -> Self {
let mut inner_state = state;
let content_fn = move |ui: &mut dyn oxiui_core::UiCtx| {
content(ui, &mut inner_state);
};
self.content = Some(Box::new(content_fn));
self
}
pub fn with_frame_skip(mut self, enabled: bool) -> Self {
self.frame_skip = enabled;
self
}
#[cfg(feature = "egui")]
#[cfg_attr(docsrs, doc(cfg(feature = "egui")))]
pub fn with_egui_ctx(mut self, f: impl FnMut(&egui::Context) + Send + 'static) -> Self {
self.egui_frame_hooks.push(Box::new(f));
self
}
#[cfg(feature = "table")]
#[cfg_attr(docsrs, doc(cfg(feature = "table")))]
pub fn table<S: oxiui_table::RowSource + Send + 'static>(mut self, source: S) -> Self {
let source = std::sync::Arc::new(std::sync::Mutex::new(source));
self = self.content(move |ui| {
if let Ok(src) = source.lock() {
for col in src.column_defs() {
ui.label(col.name.as_str());
}
let row_count = src.row_count();
for i in 0..row_count {
let cells = src.row(i);
for cell in &cells {
ui.label(&cell.to_string());
}
}
}
});
self
}
pub fn on_init<F>(mut self, f: F) -> Self
where
F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
{
self.on_init.push(Box::new(f));
self
}
pub fn on_frame<F>(mut self, f: F) -> Self
where
F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
{
self.on_frame.push(Box::new(f));
self
}
pub fn on_close<F>(mut self, f: F) -> Self
where
F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
{
self.on_close.push(Box::new(f));
self
}
pub fn on_resize<F>(mut self, f: F) -> Self
where
F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
{
self.on_resize.push(Box::new(f));
self
}
pub fn on_focus<F>(mut self, f: F) -> Self
where
F: FnMut(&mut dyn UiCtx) + Send + Sync + 'static,
{
self.on_focus.push(Box::new(f));
self
}
pub fn plugin<P: Plugin + 'static>(mut self, p: P) -> Self {
self.plugins.push(Box::new(p));
self
}
pub fn notify(
mut self,
title: impl Into<String>,
body: impl Into<String>,
urgency: u8,
) -> Self {
self.notifications.enqueue(title, body, urgency);
self
}
pub fn try_hotkey(
mut self,
mods: Modifiers,
key: Key,
action: impl Into<String>,
) -> Result<Self, HotkeyConflict> {
let action_str: String = action.into();
self.hotkeys
.register(action_str.clone(), mods, key, move || {})
.map_err(|message| HotkeyConflict { message })?;
Ok(self)
}
pub fn register_command(mut self, name: impl Into<String>, shortcut: Option<String>) -> Self {
let name: String = name.into();
self.commands
.register_with_shortcut(name.clone(), name, shortcut, || {});
self
}
pub fn command_matches(&self, query: &str) -> Vec<String> {
self.commands
.search(query)
.into_iter()
.map(|c| c.label.clone())
.collect()
}
pub fn screenshot(&self) -> Result<Vec<u8>, UiError> {
#[cfg(feature = "software")]
{
let w = if self.config.width > 0.0 {
self.config.width as u32
} else {
800
};
let h = if self.config.height > 0.0 {
self.config.height as u32
} else {
600
};
let buf = oxiui_render_soft::headless::render_headless_once(w, h);
let tmp_path = std::env::temp_dir().join(format!("oxiui_screenshot_{w}x{h}.png"));
buf.save_png(&tmp_path)
.map_err(|e| UiError::Backend(e.to_string()))?;
let bytes = std::fs::read(&tmp_path).map_err(|e| UiError::Backend(e.to_string()))?;
let _ = std::fs::remove_file(&tmp_path);
Ok(bytes)
}
#[cfg(not(feature = "software"))]
Err(UiError::Unsupported(
"App::screenshot() requires the `software` feature to be enabled.".to_string(),
))
}
pub fn run_with_return<T>(
self,
content: impl FnOnce(&mut dyn UiCtx) -> T + 'static,
) -> Result<T, UiError> {
struct NullUiCtx;
impl UiCtx for NullUiCtx {
fn heading(&mut self, _text: &str) {}
fn label(&mut self, _text: &str) {}
fn button(&mut self, _label: &str) -> ButtonResponse {
ButtonResponse::default()
}
}
let mut null = NullUiCtx;
let result = content(&mut null);
Ok(result)
}
pub fn notifications(&self) -> &NotificationQueue {
&self.notifications
}
pub fn hotkeys(&self) -> &HotkeyRegistry {
&self.hotkeys
}
pub fn extra_fonts(&self) -> &[(String, Vec<u8>)] {
&self.config.extra_fonts
}
pub fn run(self) -> Result<AppExit, UiError> {
#[cfg(feature = "iced")]
if let Backend::Iced = &self.backend {
return self.run_iced();
}
#[cfg(feature = "slint")]
if let Backend::Slint = &self.backend {
return self.run_slint_backend();
}
#[cfg(feature = "dioxus")]
if let Backend::Dioxus = &self.backend {
return self.run_dioxus_backend();
}
self.run_egui_or_fallback()
}
#[cfg(feature = "slint")]
fn run_slint_backend(mut self) -> Result<AppExit, UiError> {
use oxiui_slint::run_slint;
let theme_ref = self.theme.as_ref();
if let Some(content) = self.content.take() {
let mut content_fn = content;
run_slint(theme_ref, move |ui| content_fn(ui)).map(|()| AppExit::Ok)
} else {
run_slint(theme_ref, |_ui| {}).map(|()| AppExit::Ok)
}
}
#[cfg(feature = "dioxus")]
fn run_dioxus_backend(mut self) -> Result<AppExit, UiError> {
use oxiui_dioxus::run_dioxus;
let theme_ref = self.theme.as_ref();
if let Some(content) = self.content.take() {
let mut content_fn = content;
run_dioxus(theme_ref, move |ui| content_fn(ui)).map(|()| AppExit::Ok)
} else {
run_dioxus(theme_ref, |_ui| {}).map(|()| AppExit::Ok)
}
}
#[cfg(feature = "iced")]
fn run_iced(self) -> Result<AppExit, UiError> {
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use oxiui_iced::palette_to_iced_theme;
let iced_theme = {
let palette = self.theme.palette().clone();
palette_to_iced_theme(&palette)
};
let mut plugins = self.plugins;
plugins.sort_by_key(|p| p.priority());
let state = iced_app::OxiIcedState {
title: self.config.title.clone(),
content: RefCell::new(self.content),
pending_clicks: RefCell::new(HashSet::new()),
widget_state: RefCell::new(HashMap::new()),
on_init: RefCell::new(self.on_init),
on_frame: RefCell::new(self.on_frame),
plugins: RefCell::new(plugins),
initialised: Cell::new(false),
};
iced_app::run(state, iced_theme, self.config.width, self.config.height)
.map(|()| AppExit::Ok)
.map_err(|e| UiError::Backend(e.to_string()))
}
#[cfg(all(feature = "egui", not(target_arch = "wasm32")))]
fn run_egui_or_fallback(mut self) -> Result<AppExit, UiError> {
use eframe::NativeOptions;
use oxiui_egui::palette_to_egui_visuals;
let palette = self.theme.palette().clone();
let title = self.config.title.clone();
let width = self.config.width;
let height = self.config.height;
let visuals = palette_to_egui_visuals(&palette);
let content_fn = self.content.take();
let extra_fonts = std::mem::take(&mut self.config.extra_fonts);
self.plugins.sort_by_key(|p| p.priority());
let icon_data: Option<std::sync::Arc<egui::IconData>> =
if let Some(icon_bytes) = &self.config.icon {
match crate::icon::decode_icon(icon_bytes) {
Ok(data) => Some(std::sync::Arc::new(data)),
Err(e) => {
eprintln!("oxiui: failed to decode window icon: {e}");
None
}
}
} else {
None
};
let mut vp = egui::ViewportBuilder::default()
.with_title(&title)
.with_inner_size([width, height])
.with_resizable(self.config.resizable)
.with_decorations(self.config.decorations)
.with_transparent(self.config.transparent);
if self.config.always_on_top {
vp = vp.with_always_on_top();
}
if let Some((min_w, min_h)) = self.config.min_size {
vp = vp.with_min_inner_size([min_w, min_h]);
}
if let Some((max_w, max_h)) = self.config.max_size {
vp = vp.with_max_inner_size([max_w, max_h]);
}
if let Some((px, py)) = self.config.position {
vp = vp.with_position([px, py]);
}
if let Some(icon) = icon_data {
vp = vp.with_icon(icon);
}
let native_opts = NativeOptions {
viewport: vp,
..Default::default()
};
let frame_skip = self.frame_skip;
let egui_frame_hooks = std::mem::take(&mut self.egui_frame_hooks);
eframe::run_native(
&title,
native_opts,
Box::new(move |cc| {
cc.egui_ctx.set_visuals(visuals.clone());
if !extra_fonts.is_empty() {
let refs: Vec<(&str, Vec<u8>)> = extra_fonts
.iter()
.map(|(n, b)| (n.as_str(), b.clone()))
.collect();
let _ = oxiui_egui::load_fonts_into_egui(&refs, &cc.egui_ctx);
}
Ok(Box::new(OxiEguiApp {
content: content_fn,
on_init: self.on_init,
on_frame: self.on_frame,
plugins: self.plugins,
initialised: false,
frame_skip,
egui_frame_hooks,
}))
}),
)
.map(|()| AppExit::Ok)
.map_err(|e| UiError::Backend(e.to_string()))
}
#[cfg(all(feature = "egui", target_arch = "wasm32"))]
fn run_egui_or_fallback(self) -> Result<AppExit, UiError> {
let _ = &self.config;
let _ = &self.theme;
let _ = &self.content;
let _ = &self.backend;
let _ = &self.on_init;
let _ = &self.on_frame;
let _ = &self.plugins;
let _ = &self.frame_skip;
let _ = &self.egui_frame_hooks;
Err(UiError::Unsupported(
"On wasm32, use `oxiui_web::mount(canvas_id)` instead of App::run().".to_string(),
))
}
#[cfg(not(feature = "egui"))]
fn run_egui_or_fallback(self) -> Result<AppExit, UiError> {
let _ = &self.config;
let _ = &self.theme;
let _ = &self.content;
let _ = &self.backend;
let _ = &self.on_init;
let _ = &self.on_frame;
let _ = &self.plugins;
let _ = &self.frame_skip;
Err(UiError::Unsupported(
"No UI backend enabled. Use default features or enable `egui`.".to_string(),
))
}
pub fn run_headless_once(mut self) -> Result<AppExit, UiError> {
struct NullUiCtx;
impl UiCtx for NullUiCtx {
fn heading(&mut self, _text: &str) {}
fn label(&mut self, _text: &str) {}
fn button(&mut self, _label: &str) -> ButtonResponse {
ButtonResponse::default()
}
}
self.plugins.sort_by_key(|p| p.priority());
let mut null = NullUiCtx;
for hook in self.on_init.iter_mut() {
hook(&mut null);
}
for plugin in self.plugins.iter_mut() {
plugin.init(&mut null);
}
if let Some(ref mut f) = self.content {
f(&mut null);
}
for hook in self.on_frame.iter_mut() {
hook(&mut null);
}
for plugin in self.plugins.iter_mut() {
plugin.update(&mut null);
}
Ok(AppExit::Ok)
}
#[cfg(feature = "a11y")]
pub fn build_a11y_snapshot(
&mut self,
window_id: oxiui_accessibility::WindowA11yId,
) -> oxiui_accessibility::A11yTree {
let mut recorder = recording::RecordingUiCtx::new();
if let Some(ref mut f) = self.content {
f(&mut recorder);
}
recorder.build_a11y_tree(window_id)
}
}
#[cfg(all(feature = "egui", not(target_arch = "wasm32")))]
struct OxiEguiApp {
content: Option<ContentFn>,
on_init: Vec<HookFn>,
on_frame: Vec<HookFn>,
plugins: Vec<Box<dyn Plugin>>,
initialised: bool,
frame_skip: bool,
egui_frame_hooks: Vec<EguiFrameHook>,
}
#[cfg(all(feature = "egui", not(target_arch = "wasm32")))]
impl eframe::App for OxiEguiApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let egui_ctx = ui.ctx().clone();
let mut ctx_bridge = oxiui_egui::EguiUiCtx::new(ui);
if !self.initialised {
self.initialised = true;
for hook in self.on_init.iter_mut() {
hook(&mut ctx_bridge);
}
for plugin in self.plugins.iter_mut() {
plugin.init(&mut ctx_bridge);
}
}
if let Some(ref mut f) = self.content {
f(&mut ctx_bridge);
}
for hook in self.on_frame.iter_mut() {
hook(&mut ctx_bridge);
}
for plugin in self.plugins.iter_mut() {
plugin.update(&mut ctx_bridge);
}
for hook in &mut self.egui_frame_hooks {
hook(&egui_ctx);
}
if self.frame_skip && egui_ctx.input(|i| i.events.is_empty()) {
egui_ctx.request_repaint_after(std::time::Duration::from_secs(1));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxiui_core::events::{Key, Modifiers};
#[test]
fn test_iced_plugin_init_called() {
use std::sync::{Arc, Mutex};
struct SpyPlugin {
counter: Arc<Mutex<u32>>,
}
impl Plugin for SpyPlugin {
fn init(&mut self, _ctx: &mut dyn UiCtx) {
*self.counter.lock().unwrap() += 1;
}
fn update(&mut self, _ctx: &mut dyn UiCtx) {}
}
let counter = Arc::new(Mutex::new(0u32));
let counter_c = Arc::clone(&counter);
App::new(AppConfig::new())
.plugin(SpyPlugin { counter: counter_c })
.run_headless_once()
.unwrap();
assert_eq!(
*counter.lock().unwrap(),
1,
"plugin init must be called once"
);
}
#[test]
fn test_app_config_window_props_set() {
let cfg = AppConfig::new()
.min_size(400.0, 300.0)
.max_size(1920.0, 1080.0)
.decorations(false)
.transparent(true)
.always_on_top(true)
.icon(vec![0u8, 1, 2, 3])
.position(100.0, 200.0);
assert_eq!(cfg.min_size, Some((400.0, 300.0)));
assert_eq!(cfg.max_size, Some((1920.0, 1080.0)));
assert!(!cfg.decorations);
assert!(cfg.transparent);
assert!(cfg.always_on_top);
assert_eq!(cfg.icon, Some(vec![0u8, 1, 2, 3]));
assert_eq!(cfg.position, Some((100.0, 200.0)));
}
#[test]
fn test_app_config_defaults() {
let cfg = AppConfig::new();
assert!(cfg.decorations, "decorations defaults to true");
assert!(!cfg.transparent, "transparent defaults to false");
assert!(!cfg.always_on_top, "always_on_top defaults to false");
assert!(cfg.min_size.is_none());
assert!(cfg.max_size.is_none());
assert!(cfg.icon.is_none());
assert!(cfg.position.is_none());
}
#[test]
fn test_app_notify_enqueues() {
let app = App::new(AppConfig::new()).notify("Alert", "Something happened", 1);
assert_eq!(
app.notifications().len(),
1,
"one notification must be enqueued"
);
let n = app.notifications.pending.iter().next().unwrap();
assert_eq!(n.title, "Alert");
assert_eq!(n.body, "Something happened");
assert_eq!(n.urgency, 1);
}
#[test]
fn test_app_hotkey_conflict_detection() {
let mods = Modifiers {
ctrl: true,
..Modifiers::NONE
};
let key = Key::Character("s".into());
let app = App::new(AppConfig::new())
.try_hotkey(mods, key.clone(), "save")
.expect("first registration must succeed");
let result = app.try_hotkey(mods, key, "save-duplicate");
assert!(result.is_err(), "duplicate hotkey must return Err");
}
#[test]
fn test_hotkey_conflict_error_type() {
let mods = Modifiers::NONE;
let key = Key::Escape;
let app = App::new(AppConfig::new())
.try_hotkey(mods, key.clone(), "esc")
.unwrap();
match app.try_hotkey(mods, key, "esc2") {
Err(err) => assert!(!err.message.is_empty()),
Ok(_) => panic!("expected HotkeyConflict error"),
}
}
#[test]
fn test_command_palette_fuzzy_match() {
let app = App::new(AppConfig::new())
.register_command("Save File", None)
.register_command("Open File", None)
.register_command("Quit", None);
let matches = app.command_matches("save");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0], "Save File");
}
#[test]
fn test_command_palette_empty_query_matches_all() {
let app = App::new(AppConfig::new())
.register_command("Alpha", None)
.register_command("Beta", None);
let matches = app.command_matches("");
assert_eq!(matches.len(), 2);
}
#[test]
fn test_screenshot_returns_nonempty_or_unsupported() {
let app = App::new(AppConfig::new().size(64.0, 48.0));
let result = app.screenshot();
match result {
Ok(bytes) => assert!(!bytes.is_empty(), "screenshot bytes must be non-empty"),
Err(UiError::Unsupported(_)) => {
}
Err(e) => panic!("unexpected screenshot error: {e:?}"),
}
}
#[test]
fn test_run_with_return_headless() {
let app = App::new(AppConfig::new());
let result = app.run_with_return(|_ui| 42u32);
assert_eq!(result.unwrap(), 42u32);
}
#[test]
fn test_run_with_return_string_value() {
let app = App::new(AppConfig::new());
let result = app.run_with_return(|_ui| "hello".to_string());
assert_eq!(result.unwrap(), "hello");
}
#[test]
fn test_lifecycle_on_close_registered() {
let _app = App::new(AppConfig::new()).on_close(|_ui| {});
}
#[test]
fn test_lifecycle_on_resize_registered() {
let _app = App::new(AppConfig::new()).on_resize(|_ui| {});
}
#[test]
fn test_lifecycle_on_focus_registered() {
let _app = App::new(AppConfig::new()).on_focus(|_ui| {});
}
#[test]
fn test_app_exit_richer_reason() {
let r1 = AppExit::RequestedByUser;
let r2 = AppExit::Programmatic("deliberate shutdown".into());
let r3 = AppExit::Ok;
assert_eq!(r1, AppExit::RequestedByUser);
assert_eq!(r2, AppExit::Programmatic("deliberate shutdown".into()));
assert_ne!(r1, r3);
assert_ne!(r2, AppExit::Programmatic("other".into()));
}
#[test]
fn test_prelude_exports_uictx() {
use crate::prelude::*;
fn _accepts_ctx(_: &dyn UiCtx) {}
}
#[test]
fn test_headless_smoke_all_apis() {
use std::sync::{Arc, Mutex};
struct CountPlugin(Arc<Mutex<u32>>);
impl Plugin for CountPlugin {
fn init(&mut self, _: &mut dyn UiCtx) {
*self.0.lock().unwrap() += 10;
}
fn update(&mut self, _: &mut dyn UiCtx) {
*self.0.lock().unwrap() += 1;
}
}
let counter = Arc::new(Mutex::new(0u32));
App::new(
AppConfig::new()
.title("smoke")
.min_size(100.0, 100.0)
.decorations(true)
.transparent(false),
)
.plugin(CountPlugin(Arc::clone(&counter)))
.on_init(|_| {})
.on_frame(|_| {})
.notify("Test", "body", 0)
.content(|ui| {
ui.heading("h");
})
.run_headless_once()
.unwrap();
let c = *counter.lock().unwrap();
assert_eq!(c, 11, "init=10, update=1");
}
}