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}