Skip to main content

oxiui_core/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! `oxiui-core` — Pure-Rust UI core traits and types.
4//!
5//! Zero external dependencies. Adapters (`oxiui-egui`, `oxiui-render-wgpu`, …)
6//! implement the traits defined here; the `oxiui` facade wires them together.
7//!
8//! In addition to the immediate-mode trait surface (`UiCtx`, `Widget`, …) the
9//! crate provides foundational building blocks consumed across the stack:
10//!
11//! - [`geometry`] — `Point`, `Size`, `Rect`, `Insets`, `Constraints`.
12//! - [`events`] — `MouseButton`, `Modifiers`, `Key`, `ScrollDelta`.
13//! - [`tree`] — a retained `WidgetTree` with stable ids and hit testing.
14//! - [`layout`] — a single-line flexbox solver (`FlexLayout`).
15
16pub mod anim;
17pub mod cache;
18pub mod color_space;
19pub mod diff;
20pub mod dispatch;
21pub mod events;
22pub mod focus;
23pub mod geometry;
24pub mod grid;
25pub mod layout;
26pub mod paint;
27pub mod reactive;
28pub mod response;
29pub mod scheduler;
30pub mod solver;
31pub mod style;
32pub mod text_style;
33pub mod tree;
34pub mod widget_ext;
35pub mod window;
36
37pub use anim::{Animator, Easing, Spring, Transition};
38pub use cache::LayoutCache;
39pub use color_space::{
40    contrast_ratio, ContrastWarning, Hsla, LinearRgba, Oklcha, PaletteBuilder, WcagLevel,
41};
42pub use diff::{diff, DiffOp};
43pub use dispatch::{DispatchEvent, EventDispatcher, EventHandler, HandlerCtx, Phase};
44pub use events::{
45    GestureKind, Key, KeyboardEvent, Modifiers, MouseButton, MouseEvent, PhysicalKey, Propagation,
46    ScrollDelta, TouchEvent,
47};
48pub use focus::FocusManager;
49pub use geometry::{Constraints, Insets, Point, Rect, Size};
50pub use grid::{
51    compute_grid, GridItem, GridLine, GridPlacement, GridSpan, GridTemplate, TrackSizing,
52};
53pub use layout::{
54    layout_subtrees_parallel, AlignContent, AlignItems, FlexDirection, FlexItem, FlexLayout,
55    FlexWrap, JustifyContent, LayoutTask,
56};
57pub use paint::{BlendMode, DrawCommand, DrawList, RenderBackend};
58pub use reactive::{Computed, ReactiveError, ReactiveRuntime, Signal};
59pub use response::{
60    CheckboxResponse, DropdownResponse, SliderResponse, TextAreaResponse, TextInputResponse,
61    WidgetResponse,
62};
63pub use scheduler::{Debounce, Scheduler, Throttle, TimerId};
64pub use solver::{Constraint, Expression, RelOp, Solver, SolverError, Strength, Term, Variable};
65pub use style::{Border, BorderStyle, CursorShape, Margin, Padding};
66pub use text_style::TextStyle;
67pub use tree::{WidgetId, WidgetIdAllocator, WidgetNode, WidgetTree};
68pub use widget_ext::{ClipboardProvider, DragData, DragSource, DropEffect, DropTarget, WidgetExt};
69pub use window::{WindowChannel, WindowConfig, WindowEvent, WindowId, WindowManager};
70
71/// RGBA colour value, one `u8` per channel: `Color(r, g, b, a)`.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, oxicode::Encode, oxicode::Decode)]
73pub struct Color(pub u8, pub u8, pub u8, pub u8);
74
75/// A palette of semantic colours for a UI theme.
76#[derive(Clone, Debug, PartialEq, oxicode::Encode, oxicode::Decode)]
77pub struct Palette {
78    /// Window / page background colour.
79    pub background: Color,
80    /// Card / panel surface colour.
81    pub surface: Color,
82    /// Primary accent colour.
83    pub primary: Color,
84    /// Text drawn on top of the primary colour.
85    pub on_primary: Color,
86    /// Main body text colour.
87    pub text: Color,
88    /// De-emphasised / disabled text colour.
89    pub muted: Color,
90}
91
92impl Palette {
93    /// Construct a [`Palette`] with explicit colour values.
94    pub fn new(
95        background: Color,
96        surface: Color,
97        primary: Color,
98        on_primary: Color,
99        text: Color,
100        muted: Color,
101    ) -> Self {
102        Self {
103            background,
104            surface,
105            primary,
106            on_primary,
107            text,
108            muted,
109        }
110    }
111}
112
113impl Default for Palette {
114    /// Returns a neutral light-mode palette (white background, indigo-500 accent).
115    fn default() -> Self {
116        Self {
117            background: Color(255, 255, 255, 255),
118            surface: Color(245, 245, 245, 255),
119            primary: Color(99, 102, 241, 255),
120            on_primary: Color(255, 255, 255, 255),
121            text: Color(15, 23, 42, 255),
122            muted: Color(100, 116, 139, 255),
123        }
124    }
125}
126
127/// The slant style of a font face.
128#[derive(Clone, Copy, Debug, Default, PartialEq, oxicode::Encode, oxicode::Decode)]
129pub enum FontStyle {
130    /// Upright (no slant).
131    #[default]
132    Normal,
133    /// True italic (a distinct, cursive face).
134    Italic,
135    /// Oblique — the upright face slanted by `degrees` (synthetic slant).
136    Oblique {
137        /// Slant angle in degrees (positive leans right).
138        degrees: f32,
139    },
140}
141
142/// An OpenType feature tag toggle, e.g. `"liga"` on, `"tnum"` on.
143///
144/// `tag` is the 4-byte OpenType feature tag; `value` is the feature selector
145/// (0 = off, 1 = on, or a stylistic-set index).
146#[derive(Clone, Debug, PartialEq, Eq, Hash, oxicode::Encode, oxicode::Decode)]
147pub struct FontFeature {
148    /// The 4-character OpenType feature tag (e.g. `"liga"`, `"smcp"`, `"ss01"`).
149    pub tag: String,
150    /// The feature value (0 = off, 1 = on, or an index for alternates).
151    pub value: u32,
152}
153
154impl FontFeature {
155    /// Enable a feature (value `1`).
156    pub fn on(tag: impl Into<String>) -> Self {
157        Self {
158            tag: tag.into(),
159            value: 1,
160        }
161    }
162
163    /// Disable a feature (value `0`).
164    pub fn off(tag: impl Into<String>) -> Self {
165        Self {
166            tag: tag.into(),
167            value: 0,
168        }
169    }
170
171    /// A feature with an explicit selector value.
172    pub fn value(tag: impl Into<String>, value: u32) -> Self {
173        Self {
174            tag: tag.into(),
175            value,
176        }
177    }
178}
179
180/// Font specification for UI text.
181///
182/// The three legacy fields (`family`, `size`, `weight`) plus the
183/// [`FontSpec::new`] constructor are unchanged. The richer typographic fields
184/// (`style`, `letter_spacing`, `line_height`, `features`) are additive and
185/// default to "no override", so existing call sites are unaffected.
186#[derive(Clone, Debug, PartialEq, oxicode::Encode, oxicode::Decode)]
187pub struct FontSpec {
188    /// Font family name.
189    pub family: String,
190    /// Font size in points.
191    pub size: f32,
192    /// Font weight (100 thin … 900 black; 400 is regular).
193    pub weight: u16,
194    /// Slant style (normal / italic / oblique).
195    pub style: FontStyle,
196    /// Additional inter-character spacing in points (`0.0` = font default).
197    pub letter_spacing: f32,
198    /// Line height (leading) in points. `None` uses the font's natural metrics.
199    pub line_height: Option<f32>,
200    /// OpenType feature toggles applied to runs using this spec.
201    pub features: Vec<FontFeature>,
202}
203
204impl FontSpec {
205    /// Construct a [`FontSpec`] with explicit `family`/`size`/`weight`; the
206    /// typographic extras default to "no override".
207    pub fn new(family: impl Into<String>, size: f32, weight: u16) -> Self {
208        Self {
209            family: family.into(),
210            size,
211            weight,
212            style: FontStyle::Normal,
213            letter_spacing: 0.0,
214            line_height: None,
215            features: Vec::new(),
216        }
217    }
218
219    /// Builder: set the slant [`FontStyle`].
220    pub fn with_style(mut self, style: FontStyle) -> Self {
221        self.style = style;
222        self
223    }
224
225    /// Builder: set additional letter spacing in points.
226    pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
227        self.letter_spacing = letter_spacing;
228        self
229    }
230
231    /// Builder: set the line height (leading) in points.
232    pub fn with_line_height(mut self, line_height: f32) -> Self {
233        self.line_height = Some(line_height);
234        self
235    }
236
237    /// Builder: append an OpenType [`FontFeature`].
238    pub fn with_feature(mut self, feature: FontFeature) -> Self {
239        self.features.push(feature);
240        self
241    }
242
243    /// Returns `true` if the face is italic or oblique.
244    pub fn is_slanted(&self) -> bool {
245        !matches!(self.style, FontStyle::Normal)
246    }
247}
248
249impl Default for FontSpec {
250    /// Returns Inter / 14 pt / regular (400), upright, no overrides.
251    fn default() -> Self {
252        Self::new("Inter", 14.0, 400)
253    }
254}
255
256/// A styled text span for use with [`UiCtx::rich_text`].
257///
258/// Each span carries its own typographic style, allowing a single call to
259/// `rich_text` to render mixed-style text (bold headings, coloured links, …).
260#[derive(Clone, Debug)]
261pub struct RichTextSpan {
262    /// The text content of this span.
263    pub text: String,
264    /// Render the text in bold weight.
265    pub bold: bool,
266    /// Render the text in italic.
267    pub italic: bool,
268    /// RGBA colour bytes `[r, g, b, a]`.
269    pub color: [u8; 4],
270    /// Font size in logical pixels.
271    pub font_size: f32,
272    /// Optional font-family override; `None` uses the theme default.
273    pub font_family: Option<String>,
274}
275
276impl RichTextSpan {
277    /// Construct a span with default style (black, 16 px, upright).
278    pub fn new(text: impl Into<String>) -> Self {
279        RichTextSpan {
280            text: text.into(),
281            bold: false,
282            italic: false,
283            color: [0, 0, 0, 255],
284            font_size: 16.0,
285            font_family: None,
286        }
287    }
288
289    /// Builder: enable bold weight.
290    pub fn bold(mut self) -> Self {
291        self.bold = true;
292        self
293    }
294
295    /// Builder: enable italic.
296    pub fn italic(mut self) -> Self {
297        self.italic = true;
298        self
299    }
300
301    /// Builder: set the RGBA colour.
302    pub fn color(mut self, c: [u8; 4]) -> Self {
303        self.color = c;
304        self
305    }
306
307    /// Builder: set the font size in logical pixels.
308    pub fn font_size(mut self, s: f32) -> Self {
309        self.font_size = s;
310        self
311    }
312
313    /// Builder: set an optional font-family override.
314    pub fn font_family(mut self, family: impl Into<String>) -> Self {
315        self.font_family = Some(family.into());
316        self
317    }
318}
319
320/// Response from a button widget.
321#[derive(Clone, Debug, Default)]
322pub struct ButtonResponse {
323    /// Whether the button was clicked in this frame.
324    pub clicked: bool,
325    /// Whether the cursor is hovering over the button.
326    pub hovered: bool,
327}
328
329/// Layout axis.
330#[derive(Clone, Debug)]
331pub enum Axis {
332    /// Stack children from top to bottom.
333    Vertical,
334    /// Stack children from left to right.
335    Horizontal,
336}
337
338/// Events that the UI backend can emit.
339///
340/// This enum is `#[non_exhaustive]` — match arms must include a catch-all
341/// (`_ => {}`) to remain forward-compatible as new variants are added.
342///
343/// When deserialising with serde (`feature = "serde"`), unknown variants will
344/// produce a serde error. This is intentional: the API contract only promises
345/// forward-compatibility at the Rust source level via the `#[non_exhaustive]`
346/// catch-all; JSON consumers must handle new variants themselves.
347#[derive(Clone, Debug)]
348#[non_exhaustive]
349#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
350pub enum UiEvent {
351    /// The window was resized to the given pixel dimensions.
352    Resize(u32, u32),
353    /// The user requested the window to close.
354    CloseRequested,
355    /// A keyboard key was pressed (key name / character string).
356    KeyPress(String),
357    /// Mouse cursor position.
358    Mouse {
359        /// Horizontal position in logical pixels.
360        x: f32,
361        /// Vertical position in logical pixels.
362        y: f32,
363    },
364    /// A mouse button was pressed at the given position.
365    MouseDown {
366        /// Which button was pressed.
367        button: events::MouseButton,
368        /// Horizontal position in logical pixels.
369        x: f32,
370        /// Vertical position in logical pixels.
371        y: f32,
372        /// Modifier keys held at the time of the press.
373        modifiers: events::Modifiers,
374    },
375    /// A mouse button was released at the given position.
376    MouseUp {
377        /// Which button was released.
378        button: events::MouseButton,
379        /// Horizontal position in logical pixels.
380        x: f32,
381        /// Vertical position in logical pixels.
382        y: f32,
383        /// Modifier keys held at the time of the release.
384        modifiers: events::Modifiers,
385    },
386    /// The mouse moved to a new position (no button-state change implied).
387    MouseMove {
388        /// Horizontal position in logical pixels.
389        x: f32,
390        /// Vertical position in logical pixels.
391        y: f32,
392    },
393    /// A scroll-wheel / trackpad scroll occurred.
394    Wheel(events::ScrollDelta),
395    /// A key was pressed (or auto-repeated).
396    KeyDown {
397        /// The logical key.
398        key: events::Key,
399        /// Modifier keys held.
400        modifiers: events::Modifiers,
401        /// Whether this is an auto-repeat (key held down).
402        repeat: bool,
403    },
404    /// A key was released.
405    KeyUp {
406        /// The logical key.
407        key: events::Key,
408        /// Modifier keys held.
409        modifiers: events::Modifiers,
410    },
411    /// IME preedit — composition in progress.
412    ///
413    /// `text` is the current composition string. `cursor` is the byte-offset
414    /// range `(start, end)` within `text` that should be highlighted as the
415    /// cursor/selection; `None` means no explicit cursor hint.
416    ///
417    /// Note: on the egui forwarding path the cursor range is not forwarded
418    /// (egui 0.34's `ImeEvent::Preedit` only accepts a `String`).
419    ImePreedit {
420        /// Composition string being entered.
421        text: String,
422        /// Optional byte-range cursor hint within `text`.
423        cursor: Option<(usize, usize)>,
424    },
425    /// IME commit — final committed text after composition ends.
426    ///
427    /// Callers should insert `text` into the active text-input field.
428    ImeCommit(String),
429}
430
431// ── Traits ─────────────────────────────────────────────────────────────────
432
433/// Rendering context passed to every [`Widget::render`] call.
434///
435/// The three core methods ([`heading`](UiCtx::heading), [`label`](UiCtx::label),
436/// [`button`](UiCtx::button)) are **required**: every adapter implements them.
437///
438/// The remaining widget methods are **provided with default implementations**
439/// that return a `*Response` whose `supported` field is `false` (see
440/// [`response`]). This is a deliberate design choice: an adapter that has not
441/// overridden, say, [`slider`](UiCtx::slider) reports `supported == false` to
442/// the caller rather than silently rendering nothing and pretending it worked.
443/// Adapters override the subset of extended widgets they actually support; the
444/// rest degrade visibly. Callers branch on the `supported` flag to fall back.
445pub trait UiCtx {
446    /// Render a heading-sized text string.
447    fn heading(&mut self, text: &str);
448    /// Render a body-text label.
449    fn label(&mut self, text: &str);
450    /// Render a button and return the interaction state.
451    fn button(&mut self, label: &str) -> ButtonResponse;
452
453    /// Render a single-line text-input field seeded with `text`.
454    ///
455    /// Default: unsupported (`supported = false`, empty text).
456    fn text_input(&mut self, _text: &str) -> response::TextInputResponse {
457        response::TextInputResponse::unsupported()
458    }
459
460    /// Render a multi-line text-area seeded with `text`.
461    ///
462    /// `min_rows` is a hint for the minimum number of visible lines; backends
463    /// that do not support multi-line editing fall back to this default
464    /// implementation which returns `supported = false`.
465    ///
466    /// Default: unsupported (`supported = false`, empty text, cursor at (0,0)).
467    fn text_area(&mut self, _text: &str, _min_rows: usize) -> response::TextAreaResponse {
468        response::TextAreaResponse::unsupported()
469    }
470
471    /// Render a checkbox labelled `label` in state `checked`.
472    ///
473    /// Default: unsupported (`supported = false`).
474    fn checkbox(&mut self, _label: &str, _checked: bool) -> response::CheckboxResponse {
475        response::CheckboxResponse::unsupported()
476    }
477
478    /// Render a slider over `range` at `value`.
479    ///
480    /// Default: unsupported (`supported = false`, value `0.0`).
481    fn slider(
482        &mut self,
483        _value: f64,
484        _range: core::ops::RangeInclusive<f64>,
485    ) -> response::SliderResponse {
486        response::SliderResponse::unsupported()
487    }
488
489    /// Render a dropdown of `options` with `selected` chosen.
490    ///
491    /// Default: unsupported (`supported = false`, selection `0`).
492    fn dropdown(&mut self, _options: &[&str], _selected: usize) -> response::DropdownResponse {
493        response::DropdownResponse::unsupported()
494    }
495
496    /// Render an image identified by `uri` at an optional `size`.
497    ///
498    /// Default: unsupported (`supported = false`).
499    fn image(&mut self, _uri: &str, _size: Option<Size>) -> response::WidgetResponse {
500        response::WidgetResponse::unsupported()
501    }
502
503    /// Render a separator (horizontal/vertical rule).
504    ///
505    /// Default: unsupported (`supported = false`).
506    fn separator(&mut self) -> response::WidgetResponse {
507        response::WidgetResponse::unsupported()
508    }
509
510    /// Render empty space of `size` logical pixels along the layout axis.
511    ///
512    /// Default: unsupported (`supported = false`).
513    fn spacer(&mut self, _size: f32) -> response::WidgetResponse {
514        response::WidgetResponse::unsupported()
515    }
516
517    /// Render `content` inside a scrollable region.
518    ///
519    /// Default: unsupported (`supported = false`); the closure is **not**
520    /// invoked, so a caller can detect non-support before side effects run.
521    fn scroll_area(
522        &mut self,
523        _content: &mut dyn FnMut(&mut dyn UiCtx),
524    ) -> response::WidgetResponse {
525        response::WidgetResponse::unsupported()
526    }
527
528    /// Attach a tooltip with `text` to the most recently rendered widget.
529    ///
530    /// Default: unsupported (`supported = false`).
531    fn tooltip(&mut self, _text: &str) -> response::WidgetResponse {
532        response::WidgetResponse::unsupported()
533    }
534
535    /// Render a popup containing `content`.
536    ///
537    /// Default: unsupported (`supported = false`); the closure is not invoked.
538    fn popup(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
539        response::WidgetResponse::unsupported()
540    }
541
542    /// Render a modal dialog titled `title` containing `content`.
543    ///
544    /// Default: unsupported (`supported = false`); the closure is not invoked.
545    fn modal(
546        &mut self,
547        _title: &str,
548        _content: &mut dyn FnMut(&mut dyn UiCtx),
549    ) -> response::WidgetResponse {
550        response::WidgetResponse::unsupported()
551    }
552
553    /// Lay out `content` in a horizontal row.
554    ///
555    /// Default: unsupported; the closure is **not** invoked.
556    fn horizontal(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
557        response::WidgetResponse::unsupported()
558    }
559
560    /// Lay out `content` in a vertical column.
561    ///
562    /// Default: unsupported; the closure is **not** invoked.
563    fn vertical(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
564        response::WidgetResponse::unsupported()
565    }
566
567    /// Lay out `content` in a grid with `cols` columns.
568    ///
569    /// Default: unsupported; the closure is **not** invoked.
570    fn grid(
571        &mut self,
572        _cols: usize,
573        _content: &mut dyn FnMut(&mut dyn UiCtx),
574    ) -> response::WidgetResponse {
575        response::WidgetResponse::unsupported()
576    }
577
578    /// Render a menu bar containing `content`.
579    ///
580    /// Default: unsupported; the closure is **not** invoked.
581    fn menu_bar(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
582        response::WidgetResponse::unsupported()
583    }
584
585    /// Render multi-styled text from a slice of [`RichTextSpan`]s.
586    ///
587    /// Default: unsupported (`supported = false`).
588    fn rich_text(&mut self, _spans: &[RichTextSpan]) -> response::WidgetResponse {
589        response::WidgetResponse::unsupported()
590    }
591
592    /// Render a body-text label with an explicit [`TextStyle`].
593    ///
594    /// The default implementation delegates to [`UiCtx::label`] (text is
595    /// always rendered) and ignores `_style`. Adapters that can honour rich
596    /// typography should override this method.
597    ///
598    /// Returns [`WidgetResponse::supported`] because `label` is a *required*
599    /// method — the text is guaranteed to appear even if the style is ignored.
600    fn label_styled(&mut self, text: &str, _style: TextStyle) -> response::WidgetResponse {
601        self.label(text);
602        response::WidgetResponse::supported()
603    }
604
605    /// Render a heading with an explicit [`TextStyle`].
606    ///
607    /// The default implementation delegates to [`UiCtx::heading`] (text is
608    /// always rendered) and ignores `_style`. Adapters that can honour rich
609    /// typography should override this method.
610    ///
611    /// Returns [`WidgetResponse::supported`] because `heading` is a *required*
612    /// method — the text is guaranteed to appear even if the style is ignored.
613    fn heading_styled(&mut self, text: &str, _style: TextStyle) -> response::WidgetResponse {
614        self.heading(text);
615        response::WidgetResponse::supported()
616    }
617
618    /// Mark `content` as a drag source with the given `id`.
619    ///
620    /// Default: unsupported; the closure is **not** invoked.
621    fn drag_source(
622        &mut self,
623        _id: u64,
624        _content: &mut dyn FnMut(&mut dyn UiCtx),
625    ) -> response::WidgetResponse {
626        response::WidgetResponse::unsupported()
627    }
628
629    /// Mark `content` as a drop target that accepts drags with any of the given `accept_ids`.
630    ///
631    /// Default: unsupported; the closure is **not** invoked.
632    fn drop_target(
633        &mut self,
634        _accept_ids: &[u64],
635        _content: &mut dyn FnMut(&mut dyn UiCtx),
636    ) -> response::WidgetResponse {
637        response::WidgetResponse::unsupported()
638    }
639}
640
641/// Semantic accessibility role for a widget.
642///
643/// Used by [`Widget::a11y_role`] to describe a widget's function to
644/// assistive technologies. This is a core-level lightweight enum that the
645/// `oxiui-accessibility` crate maps to the full `accesskit::Role` set.
646#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
647#[non_exhaustive]
648pub enum A11yRole {
649    /// A generic, unlabelled group of widgets.
650    Group,
651    /// A static text label (non-interactive).
652    StaticText,
653    /// An interactive button.
654    Button,
655    /// A heading / section title.
656    Heading,
657    /// A single-line text-input field.
658    TextInput,
659    /// A multi-line text-input area.
660    TextArea,
661    /// A checkbox or toggle control.
662    Checkbox,
663    /// A slider / range control.
664    Slider,
665    /// A progress bar.
666    ProgressBar,
667    /// A tab panel.
668    TabPanel,
669    /// A tab control.
670    Tab,
671    /// A scrollable list.
672    List,
673    /// A single item within a list.
674    ListItem,
675    /// A table widget.
676    Table,
677    /// A row within a table.
678    TableRow,
679    /// A cell within a table row.
680    TableCell,
681    /// A column header within a table.
682    ColumnHeader,
683    /// A dialog / modal overlay.
684    Dialog,
685    /// An image.
686    Image,
687    /// A hyperlink.
688    Link,
689    /// A menu widget.
690    Menu,
691    /// An item within a menu.
692    MenuItem,
693    /// An alert / status message.
694    Alert,
695    /// A tooltip.
696    Tooltip,
697    /// A tree widget.
698    Tree,
699    /// An item within a tree.
700    TreeItem,
701    /// Unknown / unspecified role.
702    #[default]
703    Unknown,
704}
705
706impl std::fmt::Display for A11yRole {
707    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708        let s = match self {
709            A11yRole::Group => "group",
710            A11yRole::StaticText => "statictext",
711            A11yRole::Button => "button",
712            A11yRole::Heading => "heading",
713            A11yRole::TextInput => "textinput",
714            A11yRole::TextArea => "textarea",
715            A11yRole::Checkbox => "checkbox",
716            A11yRole::Slider => "slider",
717            A11yRole::ProgressBar => "progressbar",
718            A11yRole::TabPanel => "tabpanel",
719            A11yRole::Tab => "tab",
720            A11yRole::List => "list",
721            A11yRole::ListItem => "listitem",
722            A11yRole::Table => "table",
723            A11yRole::TableRow => "row",
724            A11yRole::TableCell => "cell",
725            A11yRole::ColumnHeader => "columnheader",
726            A11yRole::Dialog => "dialog",
727            A11yRole::Image => "img",
728            A11yRole::Link => "link",
729            A11yRole::Menu => "menu",
730            A11yRole::MenuItem => "menuitem",
731            A11yRole::Alert => "alert",
732            A11yRole::Tooltip => "tooltip",
733            A11yRole::Tree => "tree",
734            A11yRole::TreeItem => "treeitem",
735            A11yRole::Unknown => "unknown",
736        };
737        write!(f, "{s}")
738    }
739}
740
741/// A UI widget that can render itself into a [`UiCtx`].
742pub trait Widget {
743    /// Render the widget into `ui`.
744    fn render(&mut self, ui: &mut dyn UiCtx);
745
746    /// Return the accessibility role for this widget.
747    ///
748    /// Adapters call this to populate the a11y tree without requiring a full
749    /// `oxiui-accessibility` dependency in core. The default returns
750    /// [`A11yRole::Unknown`].
751    fn a11y_role(&self) -> A11yRole {
752        A11yRole::Unknown
753    }
754
755    /// Return a human-readable accessibility label for this widget.
756    ///
757    /// Used as the accessible name. Returns `None` by default (no label).
758    fn a11y_label(&self) -> Option<String> {
759        None
760    }
761
762    /// Return an accessibility description (longer hint text) for this widget.
763    ///
764    /// Returns `None` by default.
765    fn a11y_description(&self) -> Option<String> {
766        None
767    }
768}
769
770/// Spacing design tokens returned by [`Theme::spacing_tokens`].
771///
772/// Provides semantic spacing values that widgets and layout engines use instead
773/// of magic numbers. The values are in logical pixels.
774#[derive(Clone, Copy, Debug, PartialEq)]
775pub struct SpacingTokens {
776    /// Extra-small spacing (e.g. icon padding). Default: 4 px.
777    pub xs: f32,
778    /// Small spacing (e.g. button inner padding). Default: 8 px.
779    pub sm: f32,
780    /// Medium spacing (e.g. form field gap). Default: 12 px.
781    pub md: f32,
782    /// Large spacing (e.g. section gap). Default: 16 px.
783    pub lg: f32,
784    /// Extra-large spacing (e.g. page margin). Default: 24 px.
785    pub xl: f32,
786}
787
788impl Default for SpacingTokens {
789    /// COOLJAPAN 4-px-based default scale.
790    fn default() -> Self {
791        Self {
792            xs: 4.0,
793            sm: 8.0,
794            md: 12.0,
795            lg: 16.0,
796            xl: 24.0,
797        }
798    }
799}
800
801/// Border design tokens returned by [`Theme::border_tokens`].
802///
803/// Semantic border widths, radii, and style used across the UI.
804#[derive(Clone, Copy, Debug, PartialEq)]
805pub struct BorderTokens {
806    /// Default border width in logical pixels (e.g. 1 px).
807    pub width: f32,
808    /// Emphasis border width (e.g. focused outline, 2 px).
809    pub width_emphasis: f32,
810    /// Small border radius (e.g. tags, 2 px).
811    pub radius_sm: f32,
812    /// Medium border radius (e.g. cards, 4 px).
813    pub radius_md: f32,
814    /// Large border radius (e.g. dialogs, 8 px).
815    pub radius_lg: f32,
816    /// Fully rounded radius (pills, 9999 px).
817    pub radius_full: f32,
818}
819
820impl Default for BorderTokens {
821    /// COOLJAPAN conventional defaults.
822    fn default() -> Self {
823        Self {
824            width: 1.0,
825            width_emphasis: 2.0,
826            radius_sm: 2.0,
827            radius_md: 4.0,
828            radius_lg: 8.0,
829            radius_full: 9999.0,
830        }
831    }
832}
833
834/// Padding design tokens returned by [`Theme::padding_tokens`].
835///
836/// Semantic padding presets for common widget types.
837#[derive(Clone, Copy, Debug, PartialEq)]
838pub struct PaddingTokens {
839    /// Padding for compact / icon-only controls (e.g. icon button).
840    pub compact: style::Padding,
841    /// Padding for standard interactive controls (e.g. button, input).
842    pub control: style::Padding,
843    /// Padding for card / panel containers.
844    pub card: style::Padding,
845    /// Padding for page / dialog content areas.
846    pub page: style::Padding,
847}
848
849impl Default for PaddingTokens {
850    /// COOLJAPAN semantic defaults derived from the spacing scale.
851    fn default() -> Self {
852        Self {
853            compact: style::Padding::symmetric(4.0, 6.0),
854            control: style::Padding::symmetric(6.0, 12.0),
855            card: style::Padding::all(16.0),
856            page: style::Padding::all(24.0),
857        }
858    }
859}
860
861/// A UI theme that provides a colour palette, font specification, and design tokens.
862///
863/// The three required methods ([`palette`](Theme::palette), [`font`](Theme::font),
864/// and the standard base) are mandatory. The design-token methods
865/// ([`spacing_tokens`](Theme::spacing_tokens),
866/// [`border_tokens`](Theme::border_tokens),
867/// [`padding_tokens`](Theme::padding_tokens)) have **default implementations**
868/// that return COOLJAPAN's standard scales so existing theme implementations
869/// continue to compile unchanged. Themes that define a custom token set should
870/// override them.
871pub trait Theme: Send + Sync {
872    /// Return the colour palette for this theme.
873    fn palette(&self) -> &Palette;
874    /// Return the font specification for this theme.
875    fn font(&self) -> &FontSpec;
876
877    /// Return the spacing design-token scale for this theme.
878    ///
879    /// Default: [`SpacingTokens::default`] (4-px-based COOLJAPAN scale).
880    fn spacing_tokens(&self) -> SpacingTokens {
881        SpacingTokens::default()
882    }
883
884    /// Return the border design tokens (widths and radii) for this theme.
885    ///
886    /// Default: [`BorderTokens::default`] (conventional 1 px / 4 px radius).
887    fn border_tokens(&self) -> BorderTokens {
888        BorderTokens::default()
889    }
890
891    /// Return the padding design token presets for this theme.
892    ///
893    /// Default: [`PaddingTokens::default`] (derived from COOLJAPAN spacing).
894    fn padding_tokens(&self) -> PaddingTokens {
895        PaddingTokens::default()
896    }
897}
898
899/// A layout strategy that controls how children are arranged.
900pub trait Layout: Send + Sync {
901    /// Primary layout axis.
902    fn axis(&self) -> Axis;
903    /// Spacing between children in logical pixels.
904    fn spacing(&self) -> f32;
905}
906
907/// An event sink that accepts UI events for processing.
908pub trait EventSink {
909    /// Push an event into the sink.
910    fn push(&mut self, event: UiEvent);
911}
912
913// ── Error ───────────────────────────────────────────────────────────────────
914
915/// Errors emitted by the OxiUI stack.
916///
917/// This enum is `#[non_exhaustive]`: downstream `match` expressions must include
918/// a catch-all (`_ => …`) so new variants can be added without a breaking
919/// change.
920#[derive(Debug)]
921#[non_exhaustive]
922pub enum UiError {
923    /// A backend (windowing / GPU initialisation) error.
924    Backend(String),
925    /// A render-pipeline error.
926    Render(String),
927    /// A window-management error.
928    Window(String),
929    /// The requested feature or backend is not available.
930    Unsupported(String),
931    /// A layout-engine error (e.g. an unsatisfiable constraint set).
932    Layout(String),
933    /// A focus-management error (e.g. focusing a non-focusable node).
934    Focus(String),
935    /// A clipboard access error (e.g. unsupported MIME type, OS denial).
936    Clipboard(String),
937    /// A drag-and-drop protocol error (e.g. rejected payload).
938    DragDrop(String),
939    /// Any other error not covered by the above variants.
940    Other(String),
941}
942
943impl std::fmt::Display for UiError {
944    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
945        match self {
946            UiError::Backend(s) => write!(f, "UI backend error: {s}"),
947            UiError::Render(s) => write!(f, "UI render error: {s}"),
948            UiError::Window(s) => write!(f, "UI window error: {s}"),
949            UiError::Unsupported(s) => write!(f, "UI unsupported: {s}"),
950            UiError::Layout(s) => write!(f, "UI layout error: {s}"),
951            UiError::Focus(s) => write!(f, "UI focus error: {s}"),
952            UiError::Clipboard(s) => write!(f, "UI clipboard error: {s}"),
953            UiError::DragDrop(s) => write!(f, "UI drag-and-drop error: {s}"),
954            UiError::Other(s) => write!(f, "UI error: {s}"),
955        }
956    }
957}
958
959impl std::error::Error for UiError {}
960
961// ── Macros ──────────────────────────────────────────────────────────────────
962
963/// Bind a GPU context from `$expr`, skipping the test when no GPU is available.
964///
965/// If the environment variable `OXIUI_GPU_TESTS` is set to `"1"`, a missing GPU
966/// causes a panic (fail-loud mode for dedicated GPU CI runners). Otherwise the
967/// test function returns early with a printed skip notice.
968///
969/// # Example
970/// ```ignore
971/// require_gpu!(ctx, ComputeContext::try_new());
972/// // ctx: ComputeContext is bound here
973/// ```
974#[macro_export]
975macro_rules! require_gpu {
976    ($ctx:ident, $expr:expr) => {
977        let $ctx = match $expr {
978            Some(c) => c,
979            None => {
980                if ::std::env::var("OXIUI_GPU_TESTS").as_deref() == Ok("1") {
981                    panic!("OXIUI_GPU_TESTS=1 but no GPU adapter is available");
982                }
983                eprintln!("[skip] no GPU adapter — test skipped");
984                return;
985            }
986        };
987    };
988}
989
990// ── Tests ───────────────────────────────────────────────────────────────────
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    #[test]
997    fn ime_preedit_event_roundtrip() {
998        let event = UiEvent::ImePreedit {
999            text: "日本語".to_string(),
1000            cursor: Some((0, 9)),
1001        };
1002        match event {
1003            UiEvent::ImePreedit { text, cursor } => {
1004                assert_eq!(text, "日本語");
1005                assert!(cursor.is_some());
1006                let (start, end) = cursor.expect("cursor should be Some");
1007                assert_eq!(start, 0);
1008                assert_eq!(end, 9);
1009            }
1010            _ => panic!("expected ImePreedit variant"),
1011        }
1012    }
1013
1014    #[test]
1015    fn ime_commit_event_roundtrip() {
1016        let event = UiEvent::ImeCommit("確定".to_string());
1017        match event {
1018            UiEvent::ImeCommit(text) => {
1019                assert_eq!(text, "確定");
1020            }
1021            _ => panic!("expected ImeCommit variant"),
1022        }
1023    }
1024
1025    #[test]
1026    fn ime_preedit_no_cursor() {
1027        let event = UiEvent::ImePreedit {
1028            text: "abc".to_string(),
1029            cursor: None,
1030        };
1031        match event {
1032            UiEvent::ImePreedit { text, cursor } => {
1033                assert_eq!(text, "abc");
1034                assert!(cursor.is_none());
1035            }
1036            _ => panic!("expected ImePreedit variant"),
1037        }
1038    }
1039
1040    #[test]
1041    fn font_spec_expansion_defaults_and_builders() {
1042        // Legacy constructor still yields upright/no-override extras.
1043        let base = FontSpec::new("Inter", 16.0, 500);
1044        assert_eq!(base.style, FontStyle::Normal);
1045        assert_eq!(base.letter_spacing, 0.0);
1046        assert!(base.line_height.is_none());
1047        assert!(base.features.is_empty());
1048        assert!(!base.is_slanted());
1049
1050        // Builders are additive and chainable.
1051        let rich = FontSpec::new("Inter", 16.0, 500)
1052            .with_style(FontStyle::Italic)
1053            .with_letter_spacing(0.5)
1054            .with_line_height(20.0)
1055            .with_feature(FontFeature::on("liga"))
1056            .with_feature(FontFeature::value("ss01", 1));
1057        assert!(rich.is_slanted());
1058        assert_eq!(rich.letter_spacing, 0.5);
1059        assert_eq!(rich.line_height, Some(20.0));
1060        assert_eq!(rich.features.len(), 2);
1061        assert_eq!(rich.features[0], FontFeature::on("liga"));
1062
1063        // Oblique carries its slant angle.
1064        let obl = FontSpec::default().with_style(FontStyle::Oblique { degrees: 12.0 });
1065        assert!(
1066            matches!(obl.style, FontStyle::Oblique { degrees } if (degrees - 12.0).abs() < 1e-6)
1067        );
1068    }
1069
1070    #[test]
1071    fn extended_uictx_defaults_report_unsupported() {
1072        // A minimal adapter that overrides only the required methods must see
1073        // every extended widget report supported == false by default.
1074        struct BareCtx;
1075        impl UiCtx for BareCtx {
1076            fn heading(&mut self, _t: &str) {}
1077            fn label(&mut self, _t: &str) {}
1078            fn button(&mut self, _l: &str) -> ButtonResponse {
1079                ButtonResponse::default()
1080            }
1081        }
1082        let mut ui = BareCtx;
1083        assert!(!ui.text_input("x").supported);
1084        assert!(!ui.text_area("x", 3).supported);
1085        assert!(!ui.checkbox("c", true).supported);
1086        assert!(!ui.slider(0.5, 0.0..=1.0).supported);
1087        assert!(!ui.dropdown(&["a", "b"], 0).supported);
1088        assert!(!ui.image("u", None).supported);
1089        assert!(!ui.separator().supported);
1090        assert!(!ui.spacer(8.0).supported);
1091        assert!(!ui.tooltip("t").supported);
1092        // Container defaults must NOT invoke their content closure.
1093        let mut invoked = false;
1094        let r = ui.scroll_area(&mut |_inner| invoked = true);
1095        assert!(!r.supported);
1096        assert!(!invoked, "unsupported scroll_area must not run content");
1097        let mut popup_invoked = false;
1098        assert!(!ui.popup(&mut |_| popup_invoked = true).supported);
1099        assert!(!popup_invoked);
1100        let mut modal_invoked = false;
1101        assert!(!ui.modal("title", &mut |_| modal_invoked = true).supported);
1102        assert!(!modal_invoked);
1103    }
1104
1105    #[test]
1106    fn ui_error_new_variants_display() {
1107        assert!(UiError::Layout("x".into()).to_string().contains("layout"));
1108        assert!(UiError::Focus("x".into()).to_string().contains("focus"));
1109        assert!(UiError::Clipboard("x".into())
1110            .to_string()
1111            .contains("clipboard"));
1112        assert!(UiError::DragDrop("x".into()).to_string().contains("drag"));
1113    }
1114
1115    #[test]
1116    fn uictx_extension_defaults_unsupported() {
1117        struct Bare;
1118        impl UiCtx for Bare {
1119            fn heading(&mut self, _: &str) {}
1120            fn label(&mut self, _: &str) {}
1121            fn button(&mut self, _: &str) -> ButtonResponse {
1122                ButtonResponse::default()
1123            }
1124        }
1125        let mut b = Bare;
1126        assert!(!b.horizontal(&mut |_| {}).supported);
1127        assert!(!b.vertical(&mut |_| {}).supported);
1128        assert!(!b.grid(2, &mut |_| {}).supported);
1129        assert!(!b.menu_bar(&mut |_| {}).supported);
1130        assert!(!b.rich_text(&[]).supported);
1131        assert!(!b.drag_source(1, &mut |_| {}).supported);
1132        assert!(!b.drop_target(&[], &mut |_| {}).supported);
1133    }
1134
1135    #[test]
1136    fn rich_text_span_builder() {
1137        let span = RichTextSpan::new("Hello")
1138            .bold()
1139            .italic()
1140            .color([255, 0, 0, 255])
1141            .font_size(24.0);
1142        assert!(span.bold);
1143        assert!(span.italic);
1144        assert_eq!(span.color, [255, 0, 0, 255]);
1145        assert_eq!(span.font_size, 24.0);
1146        assert_eq!(span.text, "Hello");
1147    }
1148
1149    // ── Theme design-token tests ─────────────────────────────────────────────
1150
1151    /// A minimal theme that only overrides the required methods.
1152    struct MinimalTheme {
1153        palette: Palette,
1154        font: FontSpec,
1155    }
1156
1157    impl Theme for MinimalTheme {
1158        fn palette(&self) -> &Palette {
1159            &self.palette
1160        }
1161        fn font(&self) -> &FontSpec {
1162            &self.font
1163        }
1164    }
1165
1166    #[test]
1167    fn theme_default_spacing_tokens() {
1168        let t = MinimalTheme {
1169            palette: Palette::default(),
1170            font: FontSpec::default(),
1171        };
1172        let s = t.spacing_tokens();
1173        // COOLJAPAN 4-px-based scale.
1174        assert!((s.xs - 4.0).abs() < 1e-6);
1175        assert!((s.sm - 8.0).abs() < 1e-6);
1176        assert!((s.md - 12.0).abs() < 1e-6);
1177        assert!((s.lg - 16.0).abs() < 1e-6);
1178        assert!((s.xl - 24.0).abs() < 1e-6);
1179    }
1180
1181    #[test]
1182    fn theme_default_border_tokens() {
1183        let t = MinimalTheme {
1184            palette: Palette::default(),
1185            font: FontSpec::default(),
1186        };
1187        let b = t.border_tokens();
1188        assert!((b.width - 1.0).abs() < 1e-6);
1189        assert!((b.width_emphasis - 2.0).abs() < 1e-6);
1190        assert!((b.radius_sm - 2.0).abs() < 1e-6);
1191        assert!((b.radius_md - 4.0).abs() < 1e-6);
1192        assert!((b.radius_lg - 8.0).abs() < 1e-6);
1193        assert!((b.radius_full - 9999.0).abs() < 1.0);
1194    }
1195
1196    #[test]
1197    fn theme_default_padding_tokens() {
1198        let t = MinimalTheme {
1199            palette: Palette::default(),
1200            font: FontSpec::default(),
1201        };
1202        let p = t.padding_tokens();
1203        // compact: symmetric(4,6)  → top=bottom=4, left=right=6
1204        assert!((p.compact.0.top - 4.0).abs() < 1e-6);
1205        assert!((p.compact.0.right - 6.0).abs() < 1e-6);
1206        // control: symmetric(6,12) → top=bottom=6, left=right=12
1207        assert!((p.control.0.top - 6.0).abs() < 1e-6);
1208        assert!((p.control.0.right - 12.0).abs() < 1e-6);
1209        // card: all(16)
1210        assert!((p.card.0.top - 16.0).abs() < 1e-6);
1211        assert!((p.card.0.left - 16.0).abs() < 1e-6);
1212        // page: all(24)
1213        assert!((p.page.0.top - 24.0).abs() < 1e-6);
1214    }
1215
1216    /// A custom theme that overrides the design-token methods.
1217    struct CustomTheme {
1218        palette: Palette,
1219        font: FontSpec,
1220    }
1221
1222    impl Theme for CustomTheme {
1223        fn palette(&self) -> &Palette {
1224            &self.palette
1225        }
1226        fn font(&self) -> &FontSpec {
1227            &self.font
1228        }
1229        fn spacing_tokens(&self) -> SpacingTokens {
1230            SpacingTokens {
1231                xs: 2.0,
1232                sm: 4.0,
1233                md: 8.0,
1234                lg: 12.0,
1235                xl: 16.0,
1236            }
1237        }
1238    }
1239
1240    #[test]
1241    fn theme_custom_spacing_overrides_default() {
1242        let t = CustomTheme {
1243            palette: Palette::default(),
1244            font: FontSpec::default(),
1245        };
1246        let s = t.spacing_tokens();
1247        assert!((s.xs - 2.0).abs() < 1e-6, "custom xs must be 2");
1248        assert!((s.sm - 4.0).abs() < 1e-6, "custom sm must be 4");
1249        // Border and padding still return defaults.
1250        let b = t.border_tokens();
1251        assert!((b.width - 1.0).abs() < 1e-6, "border default width still 1");
1252    }
1253}
1254
1255#[cfg(test)]
1256mod macro_tests {
1257    #[test]
1258    fn require_gpu_binds_some() {
1259        require_gpu!(val, Some(42u32));
1260        assert_eq!(val, 42);
1261    }
1262
1263    #[test]
1264    fn require_gpu_skips_on_none() {
1265        require_gpu!(_val, None::<u32>);
1266        // If we reach here, env is not OXIUI_GPU_TESTS=1 — that's the non-skip path.
1267        // The macro either returned early (skip) or panicked. Either way test passes if we get here.
1268    }
1269}