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** forwarded as
418 /// of egui 0.35+ — it is converted from this byte-offset range into the
419 /// char-offset `egui::ImeEvent::Preedit::active_range_chars` range (egui
420 /// 0.34 and earlier accepted only a bare `String`, so the cursor hint was
421 /// dropped there).
422 ImePreedit {
423 /// Composition string being entered.
424 text: String,
425 /// Optional byte-range cursor hint within `text`.
426 cursor: Option<(usize, usize)>,
427 },
428 /// IME commit — final committed text after composition ends.
429 ///
430 /// Callers should insert `text` into the active text-input field.
431 ImeCommit(String),
432}
433
434// ── Traits ─────────────────────────────────────────────────────────────────
435
436/// Rendering context passed to every [`Widget::render`] call.
437///
438/// The three core methods ([`heading`](UiCtx::heading), [`label`](UiCtx::label),
439/// [`button`](UiCtx::button)) are **required**: every adapter implements them.
440///
441/// The remaining widget methods are **provided with default implementations**
442/// that return a `*Response` whose `supported` field is `false` (see
443/// [`response`]). This is a deliberate design choice: an adapter that has not
444/// overridden, say, [`slider`](UiCtx::slider) reports `supported == false` to
445/// the caller rather than silently rendering nothing and pretending it worked.
446/// Adapters override the subset of extended widgets they actually support; the
447/// rest degrade visibly. Callers branch on the `supported` flag to fall back.
448pub trait UiCtx {
449 /// Render a heading-sized text string.
450 fn heading(&mut self, text: &str);
451 /// Render a body-text label.
452 fn label(&mut self, text: &str);
453 /// Render a button and return the interaction state.
454 fn button(&mut self, label: &str) -> ButtonResponse;
455
456 /// Render a single-line text-input field seeded with `text`.
457 ///
458 /// Default: unsupported (`supported = false`, empty text).
459 fn text_input(&mut self, _text: &str) -> response::TextInputResponse {
460 response::TextInputResponse::unsupported()
461 }
462
463 /// Render a multi-line text-area seeded with `text`.
464 ///
465 /// `min_rows` is a hint for the minimum number of visible lines; backends
466 /// that do not support multi-line editing fall back to this default
467 /// implementation which returns `supported = false`.
468 ///
469 /// Default: unsupported (`supported = false`, empty text, cursor at (0,0)).
470 fn text_area(&mut self, _text: &str, _min_rows: usize) -> response::TextAreaResponse {
471 response::TextAreaResponse::unsupported()
472 }
473
474 /// Render a checkbox labelled `label` in state `checked`.
475 ///
476 /// Default: unsupported (`supported = false`).
477 fn checkbox(&mut self, _label: &str, _checked: bool) -> response::CheckboxResponse {
478 response::CheckboxResponse::unsupported()
479 }
480
481 /// Render a slider over `range` at `value`.
482 ///
483 /// Default: unsupported (`supported = false`, value `0.0`).
484 fn slider(
485 &mut self,
486 _value: f64,
487 _range: core::ops::RangeInclusive<f64>,
488 ) -> response::SliderResponse {
489 response::SliderResponse::unsupported()
490 }
491
492 /// Render a dropdown of `options` with `selected` chosen.
493 ///
494 /// Default: unsupported (`supported = false`, selection `0`).
495 fn dropdown(&mut self, _options: &[&str], _selected: usize) -> response::DropdownResponse {
496 response::DropdownResponse::unsupported()
497 }
498
499 /// Render an image identified by `uri` at an optional `size`.
500 ///
501 /// Default: unsupported (`supported = false`).
502 fn image(&mut self, _uri: &str, _size: Option<Size>) -> response::WidgetResponse {
503 response::WidgetResponse::unsupported()
504 }
505
506 /// Render a separator (horizontal/vertical rule).
507 ///
508 /// Default: unsupported (`supported = false`).
509 fn separator(&mut self) -> response::WidgetResponse {
510 response::WidgetResponse::unsupported()
511 }
512
513 /// Render empty space of `size` logical pixels along the layout axis.
514 ///
515 /// Default: unsupported (`supported = false`).
516 fn spacer(&mut self, _size: f32) -> response::WidgetResponse {
517 response::WidgetResponse::unsupported()
518 }
519
520 /// Render `content` inside a scrollable region.
521 ///
522 /// Default: unsupported (`supported = false`); the closure is **not**
523 /// invoked, so a caller can detect non-support before side effects run.
524 fn scroll_area(
525 &mut self,
526 _content: &mut dyn FnMut(&mut dyn UiCtx),
527 ) -> response::WidgetResponse {
528 response::WidgetResponse::unsupported()
529 }
530
531 /// Attach a tooltip with `text` to the most recently rendered widget.
532 ///
533 /// Default: unsupported (`supported = false`).
534 fn tooltip(&mut self, _text: &str) -> response::WidgetResponse {
535 response::WidgetResponse::unsupported()
536 }
537
538 /// Render a popup containing `content`.
539 ///
540 /// Default: unsupported (`supported = false`); the closure is not invoked.
541 fn popup(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
542 response::WidgetResponse::unsupported()
543 }
544
545 /// Render a modal dialog titled `title` containing `content`.
546 ///
547 /// Default: unsupported (`supported = false`); the closure is not invoked.
548 fn modal(
549 &mut self,
550 _title: &str,
551 _content: &mut dyn FnMut(&mut dyn UiCtx),
552 ) -> response::WidgetResponse {
553 response::WidgetResponse::unsupported()
554 }
555
556 /// Lay out `content` in a horizontal row.
557 ///
558 /// Default: unsupported; the closure is **not** invoked.
559 fn horizontal(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
560 response::WidgetResponse::unsupported()
561 }
562
563 /// Lay out `content` in a vertical column.
564 ///
565 /// Default: unsupported; the closure is **not** invoked.
566 fn vertical(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
567 response::WidgetResponse::unsupported()
568 }
569
570 /// Lay out `content` in a grid with `cols` columns.
571 ///
572 /// Default: unsupported; the closure is **not** invoked.
573 fn grid(
574 &mut self,
575 _cols: usize,
576 _content: &mut dyn FnMut(&mut dyn UiCtx),
577 ) -> response::WidgetResponse {
578 response::WidgetResponse::unsupported()
579 }
580
581 /// Render a menu bar containing `content`.
582 ///
583 /// Default: unsupported; the closure is **not** invoked.
584 fn menu_bar(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
585 response::WidgetResponse::unsupported()
586 }
587
588 /// Render multi-styled text from a slice of [`RichTextSpan`]s.
589 ///
590 /// Default: unsupported (`supported = false`).
591 fn rich_text(&mut self, _spans: &[RichTextSpan]) -> response::WidgetResponse {
592 response::WidgetResponse::unsupported()
593 }
594
595 /// Render a body-text label with an explicit [`TextStyle`].
596 ///
597 /// The default implementation delegates to [`UiCtx::label`] (text is
598 /// always rendered) and ignores `_style`. Adapters that can honour rich
599 /// typography should override this method.
600 ///
601 /// Returns [`WidgetResponse::supported`] because `label` is a *required*
602 /// method — the text is guaranteed to appear even if the style is ignored.
603 fn label_styled(&mut self, text: &str, _style: TextStyle) -> response::WidgetResponse {
604 self.label(text);
605 response::WidgetResponse::supported()
606 }
607
608 /// Render a heading with an explicit [`TextStyle`].
609 ///
610 /// The default implementation delegates to [`UiCtx::heading`] (text is
611 /// always rendered) and ignores `_style`. Adapters that can honour rich
612 /// typography should override this method.
613 ///
614 /// Returns [`WidgetResponse::supported`] because `heading` is a *required*
615 /// method — the text is guaranteed to appear even if the style is ignored.
616 fn heading_styled(&mut self, text: &str, _style: TextStyle) -> response::WidgetResponse {
617 self.heading(text);
618 response::WidgetResponse::supported()
619 }
620
621 /// Mark `content` as a drag source with the given `id`.
622 ///
623 /// Default: unsupported; the closure is **not** invoked.
624 fn drag_source(
625 &mut self,
626 _id: u64,
627 _content: &mut dyn FnMut(&mut dyn UiCtx),
628 ) -> response::WidgetResponse {
629 response::WidgetResponse::unsupported()
630 }
631
632 /// Mark `content` as a drop target that accepts drags with any of the given `accept_ids`.
633 ///
634 /// Default: unsupported; the closure is **not** invoked.
635 fn drop_target(
636 &mut self,
637 _accept_ids: &[u64],
638 _content: &mut dyn FnMut(&mut dyn UiCtx),
639 ) -> response::WidgetResponse {
640 response::WidgetResponse::unsupported()
641 }
642}
643
644/// Semantic accessibility role for a widget.
645///
646/// Used by [`Widget::a11y_role`] to describe a widget's function to
647/// assistive technologies. This is a core-level lightweight enum that the
648/// `oxiui-accessibility` crate maps to the full `accesskit::Role` set.
649#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
650#[non_exhaustive]
651pub enum A11yRole {
652 /// A generic, unlabelled group of widgets.
653 Group,
654 /// A static text label (non-interactive).
655 StaticText,
656 /// An interactive button.
657 Button,
658 /// A heading / section title.
659 Heading,
660 /// A single-line text-input field.
661 TextInput,
662 /// A multi-line text-input area.
663 TextArea,
664 /// A checkbox or toggle control.
665 Checkbox,
666 /// A slider / range control.
667 Slider,
668 /// A progress bar.
669 ProgressBar,
670 /// A tab panel.
671 TabPanel,
672 /// A tab control.
673 Tab,
674 /// A scrollable list.
675 List,
676 /// A single item within a list.
677 ListItem,
678 /// A table widget.
679 Table,
680 /// A row within a table.
681 TableRow,
682 /// A cell within a table row.
683 TableCell,
684 /// A column header within a table.
685 ColumnHeader,
686 /// A dialog / modal overlay.
687 Dialog,
688 /// An image.
689 Image,
690 /// A hyperlink.
691 Link,
692 /// A menu widget.
693 Menu,
694 /// An item within a menu.
695 MenuItem,
696 /// An alert / status message.
697 Alert,
698 /// A tooltip.
699 Tooltip,
700 /// A tree widget.
701 Tree,
702 /// An item within a tree.
703 TreeItem,
704 /// Unknown / unspecified role.
705 #[default]
706 Unknown,
707}
708
709impl std::fmt::Display for A11yRole {
710 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
711 let s = match self {
712 A11yRole::Group => "group",
713 A11yRole::StaticText => "statictext",
714 A11yRole::Button => "button",
715 A11yRole::Heading => "heading",
716 A11yRole::TextInput => "textinput",
717 A11yRole::TextArea => "textarea",
718 A11yRole::Checkbox => "checkbox",
719 A11yRole::Slider => "slider",
720 A11yRole::ProgressBar => "progressbar",
721 A11yRole::TabPanel => "tabpanel",
722 A11yRole::Tab => "tab",
723 A11yRole::List => "list",
724 A11yRole::ListItem => "listitem",
725 A11yRole::Table => "table",
726 A11yRole::TableRow => "row",
727 A11yRole::TableCell => "cell",
728 A11yRole::ColumnHeader => "columnheader",
729 A11yRole::Dialog => "dialog",
730 A11yRole::Image => "img",
731 A11yRole::Link => "link",
732 A11yRole::Menu => "menu",
733 A11yRole::MenuItem => "menuitem",
734 A11yRole::Alert => "alert",
735 A11yRole::Tooltip => "tooltip",
736 A11yRole::Tree => "tree",
737 A11yRole::TreeItem => "treeitem",
738 A11yRole::Unknown => "unknown",
739 };
740 write!(f, "{s}")
741 }
742}
743
744/// A UI widget that can render itself into a [`UiCtx`].
745pub trait Widget {
746 /// Render the widget into `ui`.
747 fn render(&mut self, ui: &mut dyn UiCtx);
748
749 /// Return the accessibility role for this widget.
750 ///
751 /// Adapters call this to populate the a11y tree without requiring a full
752 /// `oxiui-accessibility` dependency in core. The default returns
753 /// [`A11yRole::Unknown`].
754 fn a11y_role(&self) -> A11yRole {
755 A11yRole::Unknown
756 }
757
758 /// Return a human-readable accessibility label for this widget.
759 ///
760 /// Used as the accessible name. Returns `None` by default (no label).
761 fn a11y_label(&self) -> Option<String> {
762 None
763 }
764
765 /// Return an accessibility description (longer hint text) for this widget.
766 ///
767 /// Returns `None` by default.
768 fn a11y_description(&self) -> Option<String> {
769 None
770 }
771}
772
773/// Spacing design tokens returned by [`Theme::spacing_tokens`].
774///
775/// Provides semantic spacing values that widgets and layout engines use instead
776/// of magic numbers. The values are in logical pixels.
777#[derive(Clone, Copy, Debug, PartialEq)]
778pub struct SpacingTokens {
779 /// Extra-small spacing (e.g. icon padding). Default: 4 px.
780 pub xs: f32,
781 /// Small spacing (e.g. button inner padding). Default: 8 px.
782 pub sm: f32,
783 /// Medium spacing (e.g. form field gap). Default: 12 px.
784 pub md: f32,
785 /// Large spacing (e.g. section gap). Default: 16 px.
786 pub lg: f32,
787 /// Extra-large spacing (e.g. page margin). Default: 24 px.
788 pub xl: f32,
789}
790
791impl Default for SpacingTokens {
792 /// COOLJAPAN 4-px-based default scale.
793 fn default() -> Self {
794 Self {
795 xs: 4.0,
796 sm: 8.0,
797 md: 12.0,
798 lg: 16.0,
799 xl: 24.0,
800 }
801 }
802}
803
804/// Border design tokens returned by [`Theme::border_tokens`].
805///
806/// Semantic border widths, radii, and style used across the UI.
807#[derive(Clone, Copy, Debug, PartialEq)]
808pub struct BorderTokens {
809 /// Default border width in logical pixels (e.g. 1 px).
810 pub width: f32,
811 /// Emphasis border width (e.g. focused outline, 2 px).
812 pub width_emphasis: f32,
813 /// Small border radius (e.g. tags, 2 px).
814 pub radius_sm: f32,
815 /// Medium border radius (e.g. cards, 4 px).
816 pub radius_md: f32,
817 /// Large border radius (e.g. dialogs, 8 px).
818 pub radius_lg: f32,
819 /// Fully rounded radius (pills, 9999 px).
820 pub radius_full: f32,
821}
822
823impl Default for BorderTokens {
824 /// COOLJAPAN conventional defaults.
825 fn default() -> Self {
826 Self {
827 width: 1.0,
828 width_emphasis: 2.0,
829 radius_sm: 2.0,
830 radius_md: 4.0,
831 radius_lg: 8.0,
832 radius_full: 9999.0,
833 }
834 }
835}
836
837/// Padding design tokens returned by [`Theme::padding_tokens`].
838///
839/// Semantic padding presets for common widget types.
840#[derive(Clone, Copy, Debug, PartialEq)]
841pub struct PaddingTokens {
842 /// Padding for compact / icon-only controls (e.g. icon button).
843 pub compact: style::Padding,
844 /// Padding for standard interactive controls (e.g. button, input).
845 pub control: style::Padding,
846 /// Padding for card / panel containers.
847 pub card: style::Padding,
848 /// Padding for page / dialog content areas.
849 pub page: style::Padding,
850}
851
852impl Default for PaddingTokens {
853 /// COOLJAPAN semantic defaults derived from the spacing scale.
854 fn default() -> Self {
855 Self {
856 compact: style::Padding::symmetric(4.0, 6.0),
857 control: style::Padding::symmetric(6.0, 12.0),
858 card: style::Padding::all(16.0),
859 page: style::Padding::all(24.0),
860 }
861 }
862}
863
864/// A UI theme that provides a colour palette, font specification, and design tokens.
865///
866/// The three required methods ([`palette`](Theme::palette), [`font`](Theme::font),
867/// and the standard base) are mandatory. The design-token methods
868/// ([`spacing_tokens`](Theme::spacing_tokens),
869/// [`border_tokens`](Theme::border_tokens),
870/// [`padding_tokens`](Theme::padding_tokens)) have **default implementations**
871/// that return COOLJAPAN's standard scales so existing theme implementations
872/// continue to compile unchanged. Themes that define a custom token set should
873/// override them.
874pub trait Theme: Send + Sync {
875 /// Return the colour palette for this theme.
876 fn palette(&self) -> &Palette;
877 /// Return the font specification for this theme.
878 fn font(&self) -> &FontSpec;
879
880 /// Return the spacing design-token scale for this theme.
881 ///
882 /// Default: [`SpacingTokens::default`] (4-px-based COOLJAPAN scale).
883 fn spacing_tokens(&self) -> SpacingTokens {
884 SpacingTokens::default()
885 }
886
887 /// Return the border design tokens (widths and radii) for this theme.
888 ///
889 /// Default: [`BorderTokens::default`] (conventional 1 px / 4 px radius).
890 fn border_tokens(&self) -> BorderTokens {
891 BorderTokens::default()
892 }
893
894 /// Return the padding design token presets for this theme.
895 ///
896 /// Default: [`PaddingTokens::default`] (derived from COOLJAPAN spacing).
897 fn padding_tokens(&self) -> PaddingTokens {
898 PaddingTokens::default()
899 }
900}
901
902/// A layout strategy that controls how children are arranged.
903pub trait Layout: Send + Sync {
904 /// Primary layout axis.
905 fn axis(&self) -> Axis;
906 /// Spacing between children in logical pixels.
907 fn spacing(&self) -> f32;
908}
909
910/// An event sink that accepts UI events for processing.
911pub trait EventSink {
912 /// Push an event into the sink.
913 fn push(&mut self, event: UiEvent);
914}
915
916// ── Error ───────────────────────────────────────────────────────────────────
917
918/// Errors emitted by the OxiUI stack.
919///
920/// This enum is `#[non_exhaustive]`: downstream `match` expressions must include
921/// a catch-all (`_ => …`) so new variants can be added without a breaking
922/// change.
923#[derive(Debug)]
924#[non_exhaustive]
925pub enum UiError {
926 /// A backend (windowing / GPU initialisation) error.
927 Backend(String),
928 /// A render-pipeline error.
929 Render(String),
930 /// A window-management error.
931 Window(String),
932 /// The requested feature or backend is not available.
933 Unsupported(String),
934 /// A layout-engine error (e.g. an unsatisfiable constraint set).
935 Layout(String),
936 /// A focus-management error (e.g. focusing a non-focusable node).
937 Focus(String),
938 /// A clipboard access error (e.g. unsupported MIME type, OS denial).
939 Clipboard(String),
940 /// A drag-and-drop protocol error (e.g. rejected payload).
941 DragDrop(String),
942 /// Any other error not covered by the above variants.
943 Other(String),
944}
945
946impl std::fmt::Display for UiError {
947 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
948 match self {
949 UiError::Backend(s) => write!(f, "UI backend error: {s}"),
950 UiError::Render(s) => write!(f, "UI render error: {s}"),
951 UiError::Window(s) => write!(f, "UI window error: {s}"),
952 UiError::Unsupported(s) => write!(f, "UI unsupported: {s}"),
953 UiError::Layout(s) => write!(f, "UI layout error: {s}"),
954 UiError::Focus(s) => write!(f, "UI focus error: {s}"),
955 UiError::Clipboard(s) => write!(f, "UI clipboard error: {s}"),
956 UiError::DragDrop(s) => write!(f, "UI drag-and-drop error: {s}"),
957 UiError::Other(s) => write!(f, "UI error: {s}"),
958 }
959 }
960}
961
962impl std::error::Error for UiError {}
963
964// ── Macros ──────────────────────────────────────────────────────────────────
965
966/// Bind a GPU context from `$expr`, skipping the test when no GPU is available.
967///
968/// If the environment variable `OXIUI_GPU_TESTS` is set to `"1"`, a missing GPU
969/// causes a panic (fail-loud mode for dedicated GPU CI runners). Otherwise the
970/// test function returns early with a printed skip notice.
971///
972/// # Example
973/// ```ignore
974/// require_gpu!(ctx, ComputeContext::try_new());
975/// // ctx: ComputeContext is bound here
976/// ```
977#[macro_export]
978macro_rules! require_gpu {
979 ($ctx:ident, $expr:expr) => {
980 let $ctx = match $expr {
981 Some(c) => c,
982 None => {
983 if ::std::env::var("OXIUI_GPU_TESTS").as_deref() == Ok("1") {
984 panic!("OXIUI_GPU_TESTS=1 but no GPU adapter is available");
985 }
986 eprintln!("[skip] no GPU adapter — test skipped");
987 return;
988 }
989 };
990 };
991}
992
993// ── Tests ───────────────────────────────────────────────────────────────────
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998
999 #[test]
1000 fn ime_preedit_event_roundtrip() {
1001 let event = UiEvent::ImePreedit {
1002 text: "日本語".to_string(),
1003 cursor: Some((0, 9)),
1004 };
1005 match event {
1006 UiEvent::ImePreedit { text, cursor } => {
1007 assert_eq!(text, "日本語");
1008 assert!(cursor.is_some());
1009 let (start, end) = cursor.expect("cursor should be Some");
1010 assert_eq!(start, 0);
1011 assert_eq!(end, 9);
1012 }
1013 _ => panic!("expected ImePreedit variant"),
1014 }
1015 }
1016
1017 #[test]
1018 fn ime_commit_event_roundtrip() {
1019 let event = UiEvent::ImeCommit("確定".to_string());
1020 match event {
1021 UiEvent::ImeCommit(text) => {
1022 assert_eq!(text, "確定");
1023 }
1024 _ => panic!("expected ImeCommit variant"),
1025 }
1026 }
1027
1028 #[test]
1029 fn ime_preedit_no_cursor() {
1030 let event = UiEvent::ImePreedit {
1031 text: "abc".to_string(),
1032 cursor: None,
1033 };
1034 match event {
1035 UiEvent::ImePreedit { text, cursor } => {
1036 assert_eq!(text, "abc");
1037 assert!(cursor.is_none());
1038 }
1039 _ => panic!("expected ImePreedit variant"),
1040 }
1041 }
1042
1043 #[test]
1044 fn font_spec_expansion_defaults_and_builders() {
1045 // Legacy constructor still yields upright/no-override extras.
1046 let base = FontSpec::new("Inter", 16.0, 500);
1047 assert_eq!(base.style, FontStyle::Normal);
1048 assert_eq!(base.letter_spacing, 0.0);
1049 assert!(base.line_height.is_none());
1050 assert!(base.features.is_empty());
1051 assert!(!base.is_slanted());
1052
1053 // Builders are additive and chainable.
1054 let rich = FontSpec::new("Inter", 16.0, 500)
1055 .with_style(FontStyle::Italic)
1056 .with_letter_spacing(0.5)
1057 .with_line_height(20.0)
1058 .with_feature(FontFeature::on("liga"))
1059 .with_feature(FontFeature::value("ss01", 1));
1060 assert!(rich.is_slanted());
1061 assert_eq!(rich.letter_spacing, 0.5);
1062 assert_eq!(rich.line_height, Some(20.0));
1063 assert_eq!(rich.features.len(), 2);
1064 assert_eq!(rich.features[0], FontFeature::on("liga"));
1065
1066 // Oblique carries its slant angle.
1067 let obl = FontSpec::default().with_style(FontStyle::Oblique { degrees: 12.0 });
1068 assert!(
1069 matches!(obl.style, FontStyle::Oblique { degrees } if (degrees - 12.0).abs() < 1e-6)
1070 );
1071 }
1072
1073 #[test]
1074 fn extended_uictx_defaults_report_unsupported() {
1075 // A minimal adapter that overrides only the required methods must see
1076 // every extended widget report supported == false by default.
1077 struct BareCtx;
1078 impl UiCtx for BareCtx {
1079 fn heading(&mut self, _t: &str) {}
1080 fn label(&mut self, _t: &str) {}
1081 fn button(&mut self, _l: &str) -> ButtonResponse {
1082 ButtonResponse::default()
1083 }
1084 }
1085 let mut ui = BareCtx;
1086 assert!(!ui.text_input("x").supported);
1087 assert!(!ui.text_area("x", 3).supported);
1088 assert!(!ui.checkbox("c", true).supported);
1089 assert!(!ui.slider(0.5, 0.0..=1.0).supported);
1090 assert!(!ui.dropdown(&["a", "b"], 0).supported);
1091 assert!(!ui.image("u", None).supported);
1092 assert!(!ui.separator().supported);
1093 assert!(!ui.spacer(8.0).supported);
1094 assert!(!ui.tooltip("t").supported);
1095 // Container defaults must NOT invoke their content closure.
1096 let mut invoked = false;
1097 let r = ui.scroll_area(&mut |_inner| invoked = true);
1098 assert!(!r.supported);
1099 assert!(!invoked, "unsupported scroll_area must not run content");
1100 let mut popup_invoked = false;
1101 assert!(!ui.popup(&mut |_| popup_invoked = true).supported);
1102 assert!(!popup_invoked);
1103 let mut modal_invoked = false;
1104 assert!(!ui.modal("title", &mut |_| modal_invoked = true).supported);
1105 assert!(!modal_invoked);
1106 }
1107
1108 #[test]
1109 fn ui_error_new_variants_display() {
1110 assert!(UiError::Layout("x".into()).to_string().contains("layout"));
1111 assert!(UiError::Focus("x".into()).to_string().contains("focus"));
1112 assert!(UiError::Clipboard("x".into())
1113 .to_string()
1114 .contains("clipboard"));
1115 assert!(UiError::DragDrop("x".into()).to_string().contains("drag"));
1116 }
1117
1118 #[test]
1119 fn uictx_extension_defaults_unsupported() {
1120 struct Bare;
1121 impl UiCtx for Bare {
1122 fn heading(&mut self, _: &str) {}
1123 fn label(&mut self, _: &str) {}
1124 fn button(&mut self, _: &str) -> ButtonResponse {
1125 ButtonResponse::default()
1126 }
1127 }
1128 let mut b = Bare;
1129 assert!(!b.horizontal(&mut |_| {}).supported);
1130 assert!(!b.vertical(&mut |_| {}).supported);
1131 assert!(!b.grid(2, &mut |_| {}).supported);
1132 assert!(!b.menu_bar(&mut |_| {}).supported);
1133 assert!(!b.rich_text(&[]).supported);
1134 assert!(!b.drag_source(1, &mut |_| {}).supported);
1135 assert!(!b.drop_target(&[], &mut |_| {}).supported);
1136 }
1137
1138 #[test]
1139 fn rich_text_span_builder() {
1140 let span = RichTextSpan::new("Hello")
1141 .bold()
1142 .italic()
1143 .color([255, 0, 0, 255])
1144 .font_size(24.0);
1145 assert!(span.bold);
1146 assert!(span.italic);
1147 assert_eq!(span.color, [255, 0, 0, 255]);
1148 assert_eq!(span.font_size, 24.0);
1149 assert_eq!(span.text, "Hello");
1150 }
1151
1152 // ── Theme design-token tests ─────────────────────────────────────────────
1153
1154 /// A minimal theme that only overrides the required methods.
1155 struct MinimalTheme {
1156 palette: Palette,
1157 font: FontSpec,
1158 }
1159
1160 impl Theme for MinimalTheme {
1161 fn palette(&self) -> &Palette {
1162 &self.palette
1163 }
1164 fn font(&self) -> &FontSpec {
1165 &self.font
1166 }
1167 }
1168
1169 #[test]
1170 fn theme_default_spacing_tokens() {
1171 let t = MinimalTheme {
1172 palette: Palette::default(),
1173 font: FontSpec::default(),
1174 };
1175 let s = t.spacing_tokens();
1176 // COOLJAPAN 4-px-based scale.
1177 assert!((s.xs - 4.0).abs() < 1e-6);
1178 assert!((s.sm - 8.0).abs() < 1e-6);
1179 assert!((s.md - 12.0).abs() < 1e-6);
1180 assert!((s.lg - 16.0).abs() < 1e-6);
1181 assert!((s.xl - 24.0).abs() < 1e-6);
1182 }
1183
1184 #[test]
1185 fn theme_default_border_tokens() {
1186 let t = MinimalTheme {
1187 palette: Palette::default(),
1188 font: FontSpec::default(),
1189 };
1190 let b = t.border_tokens();
1191 assert!((b.width - 1.0).abs() < 1e-6);
1192 assert!((b.width_emphasis - 2.0).abs() < 1e-6);
1193 assert!((b.radius_sm - 2.0).abs() < 1e-6);
1194 assert!((b.radius_md - 4.0).abs() < 1e-6);
1195 assert!((b.radius_lg - 8.0).abs() < 1e-6);
1196 assert!((b.radius_full - 9999.0).abs() < 1.0);
1197 }
1198
1199 #[test]
1200 fn theme_default_padding_tokens() {
1201 let t = MinimalTheme {
1202 palette: Palette::default(),
1203 font: FontSpec::default(),
1204 };
1205 let p = t.padding_tokens();
1206 // compact: symmetric(4,6) → top=bottom=4, left=right=6
1207 assert!((p.compact.0.top - 4.0).abs() < 1e-6);
1208 assert!((p.compact.0.right - 6.0).abs() < 1e-6);
1209 // control: symmetric(6,12) → top=bottom=6, left=right=12
1210 assert!((p.control.0.top - 6.0).abs() < 1e-6);
1211 assert!((p.control.0.right - 12.0).abs() < 1e-6);
1212 // card: all(16)
1213 assert!((p.card.0.top - 16.0).abs() < 1e-6);
1214 assert!((p.card.0.left - 16.0).abs() < 1e-6);
1215 // page: all(24)
1216 assert!((p.page.0.top - 24.0).abs() < 1e-6);
1217 }
1218
1219 /// A custom theme that overrides the design-token methods.
1220 struct CustomTheme {
1221 palette: Palette,
1222 font: FontSpec,
1223 }
1224
1225 impl Theme for CustomTheme {
1226 fn palette(&self) -> &Palette {
1227 &self.palette
1228 }
1229 fn font(&self) -> &FontSpec {
1230 &self.font
1231 }
1232 fn spacing_tokens(&self) -> SpacingTokens {
1233 SpacingTokens {
1234 xs: 2.0,
1235 sm: 4.0,
1236 md: 8.0,
1237 lg: 12.0,
1238 xl: 16.0,
1239 }
1240 }
1241 }
1242
1243 #[test]
1244 fn theme_custom_spacing_overrides_default() {
1245 let t = CustomTheme {
1246 palette: Palette::default(),
1247 font: FontSpec::default(),
1248 };
1249 let s = t.spacing_tokens();
1250 assert!((s.xs - 2.0).abs() < 1e-6, "custom xs must be 2");
1251 assert!((s.sm - 4.0).abs() < 1e-6, "custom sm must be 4");
1252 // Border and padding still return defaults.
1253 let b = t.border_tokens();
1254 assert!((b.width - 1.0).abs() < 1e-6, "border default width still 1");
1255 }
1256}
1257
1258#[cfg(test)]
1259mod macro_tests {
1260 #[test]
1261 fn require_gpu_binds_some() {
1262 require_gpu!(val, Some(42u32));
1263 assert_eq!(val, 42);
1264 }
1265
1266 #[test]
1267 fn require_gpu_skips_on_none() {
1268 require_gpu!(_val, None::<u32>);
1269 // If we reach here, env is not OXIUI_GPU_TESTS=1 — that's the non-skip path.
1270 // The macro either returned early (skip) or panicked. Either way test passes if we get here.
1271 }
1272}