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;
35
36pub use anim::{Animator, Easing, Spring, Transition};
37pub use cache::LayoutCache;
38pub use color_space::{
39    contrast_ratio, ContrastWarning, Hsla, LinearRgba, Oklcha, PaletteBuilder, WcagLevel,
40};
41pub use diff::{diff, DiffOp};
42pub use dispatch::{DispatchEvent, EventDispatcher, EventHandler, HandlerCtx, Phase};
43pub use events::{
44    GestureKind, Key, KeyboardEvent, Modifiers, MouseButton, MouseEvent, PhysicalKey, Propagation,
45    ScrollDelta, TouchEvent,
46};
47pub use focus::FocusManager;
48pub use geometry::{Constraints, Insets, Point, Rect, Size};
49pub use grid::{
50    compute_grid, GridItem, GridLine, GridPlacement, GridSpan, GridTemplate, TrackSizing,
51};
52pub use layout::{
53    AlignContent, AlignItems, FlexDirection, FlexItem, FlexLayout, FlexWrap, JustifyContent,
54};
55pub use paint::{DrawCommand, DrawList, RenderBackend};
56pub use reactive::{Computed, ReactiveError, ReactiveRuntime, Signal};
57pub use response::{
58    CheckboxResponse, DropdownResponse, SliderResponse, TextInputResponse, WidgetResponse,
59};
60pub use scheduler::{Debounce, Scheduler, Throttle, TimerId};
61pub use solver::{Constraint, Expression, RelOp, Solver, SolverError, Strength, Term, Variable};
62pub use style::{Border, BorderStyle, CursorShape, Margin, Padding};
63pub use text_style::TextStyle;
64pub use tree::{WidgetId, WidgetIdAllocator, WidgetNode, WidgetTree};
65pub use widget_ext::{ClipboardProvider, DragData, DragSource, DropEffect, DropTarget, WidgetExt};
66
67/// RGBA colour value, one `u8` per channel: `Color(r, g, b, a)`.
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
69pub struct Color(pub u8, pub u8, pub u8, pub u8);
70
71/// A palette of semantic colours for a UI theme.
72#[derive(Clone, Debug)]
73pub struct Palette {
74    /// Window / page background colour.
75    pub background: Color,
76    /// Card / panel surface colour.
77    pub surface: Color,
78    /// Primary accent colour.
79    pub primary: Color,
80    /// Text drawn on top of the primary colour.
81    pub on_primary: Color,
82    /// Main body text colour.
83    pub text: Color,
84    /// De-emphasised / disabled text colour.
85    pub muted: Color,
86}
87
88impl Palette {
89    /// Construct a [`Palette`] with explicit colour values.
90    pub fn new(
91        background: Color,
92        surface: Color,
93        primary: Color,
94        on_primary: Color,
95        text: Color,
96        muted: Color,
97    ) -> Self {
98        Self {
99            background,
100            surface,
101            primary,
102            on_primary,
103            text,
104            muted,
105        }
106    }
107}
108
109/// The slant style of a font face.
110#[derive(Clone, Copy, Debug, Default, PartialEq)]
111pub enum FontStyle {
112    /// Upright (no slant).
113    #[default]
114    Normal,
115    /// True italic (a distinct, cursive face).
116    Italic,
117    /// Oblique — the upright face slanted by `degrees` (synthetic slant).
118    Oblique {
119        /// Slant angle in degrees (positive leans right).
120        degrees: f32,
121    },
122}
123
124/// An OpenType feature tag toggle, e.g. `"liga"` on, `"tnum"` on.
125///
126/// `tag` is the 4-byte OpenType feature tag; `value` is the feature selector
127/// (0 = off, 1 = on, or a stylistic-set index).
128#[derive(Clone, Debug, PartialEq, Eq, Hash)]
129pub struct FontFeature {
130    /// The 4-character OpenType feature tag (e.g. `"liga"`, `"smcp"`, `"ss01"`).
131    pub tag: String,
132    /// The feature value (0 = off, 1 = on, or an index for alternates).
133    pub value: u32,
134}
135
136impl FontFeature {
137    /// Enable a feature (value `1`).
138    pub fn on(tag: impl Into<String>) -> Self {
139        Self {
140            tag: tag.into(),
141            value: 1,
142        }
143    }
144
145    /// Disable a feature (value `0`).
146    pub fn off(tag: impl Into<String>) -> Self {
147        Self {
148            tag: tag.into(),
149            value: 0,
150        }
151    }
152
153    /// A feature with an explicit selector value.
154    pub fn value(tag: impl Into<String>, value: u32) -> Self {
155        Self {
156            tag: tag.into(),
157            value,
158        }
159    }
160}
161
162/// Font specification for UI text.
163///
164/// The three legacy fields (`family`, `size`, `weight`) plus the
165/// [`FontSpec::new`] constructor are unchanged. The richer typographic fields
166/// (`style`, `letter_spacing`, `line_height`, `features`) are additive and
167/// default to "no override", so existing call sites are unaffected.
168#[derive(Clone, Debug, PartialEq)]
169pub struct FontSpec {
170    /// Font family name.
171    pub family: String,
172    /// Font size in points.
173    pub size: f32,
174    /// Font weight (100 thin … 900 black; 400 is regular).
175    pub weight: u16,
176    /// Slant style (normal / italic / oblique).
177    pub style: FontStyle,
178    /// Additional inter-character spacing in points (`0.0` = font default).
179    pub letter_spacing: f32,
180    /// Line height (leading) in points. `None` uses the font's natural metrics.
181    pub line_height: Option<f32>,
182    /// OpenType feature toggles applied to runs using this spec.
183    pub features: Vec<FontFeature>,
184}
185
186impl FontSpec {
187    /// Construct a [`FontSpec`] with explicit `family`/`size`/`weight`; the
188    /// typographic extras default to "no override".
189    pub fn new(family: impl Into<String>, size: f32, weight: u16) -> Self {
190        Self {
191            family: family.into(),
192            size,
193            weight,
194            style: FontStyle::Normal,
195            letter_spacing: 0.0,
196            line_height: None,
197            features: Vec::new(),
198        }
199    }
200
201    /// Builder: set the slant [`FontStyle`].
202    pub fn with_style(mut self, style: FontStyle) -> Self {
203        self.style = style;
204        self
205    }
206
207    /// Builder: set additional letter spacing in points.
208    pub fn with_letter_spacing(mut self, letter_spacing: f32) -> Self {
209        self.letter_spacing = letter_spacing;
210        self
211    }
212
213    /// Builder: set the line height (leading) in points.
214    pub fn with_line_height(mut self, line_height: f32) -> Self {
215        self.line_height = Some(line_height);
216        self
217    }
218
219    /// Builder: append an OpenType [`FontFeature`].
220    pub fn with_feature(mut self, feature: FontFeature) -> Self {
221        self.features.push(feature);
222        self
223    }
224
225    /// Returns `true` if the face is italic or oblique.
226    pub fn is_slanted(&self) -> bool {
227        !matches!(self.style, FontStyle::Normal)
228    }
229}
230
231impl Default for FontSpec {
232    /// Returns Inter / 14 pt / regular (400), upright, no overrides.
233    fn default() -> Self {
234        Self::new("Inter", 14.0, 400)
235    }
236}
237
238/// A styled text span for use with [`UiCtx::rich_text`].
239///
240/// Each span carries its own typographic style, allowing a single call to
241/// `rich_text` to render mixed-style text (bold headings, coloured links, …).
242#[derive(Clone, Debug)]
243pub struct RichTextSpan {
244    /// The text content of this span.
245    pub text: String,
246    /// Render the text in bold weight.
247    pub bold: bool,
248    /// Render the text in italic.
249    pub italic: bool,
250    /// RGBA colour bytes `[r, g, b, a]`.
251    pub color: [u8; 4],
252    /// Font size in logical pixels.
253    pub font_size: f32,
254    /// Optional font-family override; `None` uses the theme default.
255    pub font_family: Option<String>,
256}
257
258impl RichTextSpan {
259    /// Construct a span with default style (black, 16 px, upright).
260    pub fn new(text: impl Into<String>) -> Self {
261        RichTextSpan {
262            text: text.into(),
263            bold: false,
264            italic: false,
265            color: [0, 0, 0, 255],
266            font_size: 16.0,
267            font_family: None,
268        }
269    }
270
271    /// Builder: enable bold weight.
272    pub fn bold(mut self) -> Self {
273        self.bold = true;
274        self
275    }
276
277    /// Builder: enable italic.
278    pub fn italic(mut self) -> Self {
279        self.italic = true;
280        self
281    }
282
283    /// Builder: set the RGBA colour.
284    pub fn color(mut self, c: [u8; 4]) -> Self {
285        self.color = c;
286        self
287    }
288
289    /// Builder: set the font size in logical pixels.
290    pub fn font_size(mut self, s: f32) -> Self {
291        self.font_size = s;
292        self
293    }
294
295    /// Builder: set an optional font-family override.
296    pub fn font_family(mut self, family: impl Into<String>) -> Self {
297        self.font_family = Some(family.into());
298        self
299    }
300}
301
302/// Response from a button widget.
303#[derive(Clone, Debug, Default)]
304pub struct ButtonResponse {
305    /// Whether the button was clicked in this frame.
306    pub clicked: bool,
307    /// Whether the cursor is hovering over the button.
308    pub hovered: bool,
309}
310
311/// Layout axis.
312#[derive(Clone, Debug)]
313pub enum Axis {
314    /// Stack children from top to bottom.
315    Vertical,
316    /// Stack children from left to right.
317    Horizontal,
318}
319
320/// Events that the UI backend can emit.
321///
322/// This enum is `#[non_exhaustive]` — match arms must include a catch-all
323/// (`_ => {}`) to remain forward-compatible as new variants are added.
324///
325/// When deserialising with serde (`feature = "serde"`), unknown variants will
326/// produce a serde error. This is intentional: the API contract only promises
327/// forward-compatibility at the Rust source level via the `#[non_exhaustive]`
328/// catch-all; JSON consumers must handle new variants themselves.
329#[derive(Clone, Debug)]
330#[non_exhaustive]
331#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
332pub enum UiEvent {
333    /// The window was resized to the given pixel dimensions.
334    Resize(u32, u32),
335    /// The user requested the window to close.
336    CloseRequested,
337    /// A keyboard key was pressed (key name / character string).
338    KeyPress(String),
339    /// Mouse cursor position.
340    Mouse {
341        /// Horizontal position in logical pixels.
342        x: f32,
343        /// Vertical position in logical pixels.
344        y: f32,
345    },
346    /// A mouse button was pressed at the given position.
347    MouseDown {
348        /// Which button was pressed.
349        button: events::MouseButton,
350        /// Horizontal position in logical pixels.
351        x: f32,
352        /// Vertical position in logical pixels.
353        y: f32,
354        /// Modifier keys held at the time of the press.
355        modifiers: events::Modifiers,
356    },
357    /// A mouse button was released at the given position.
358    MouseUp {
359        /// Which button was released.
360        button: events::MouseButton,
361        /// Horizontal position in logical pixels.
362        x: f32,
363        /// Vertical position in logical pixels.
364        y: f32,
365        /// Modifier keys held at the time of the release.
366        modifiers: events::Modifiers,
367    },
368    /// The mouse moved to a new position (no button-state change implied).
369    MouseMove {
370        /// Horizontal position in logical pixels.
371        x: f32,
372        /// Vertical position in logical pixels.
373        y: f32,
374    },
375    /// A scroll-wheel / trackpad scroll occurred.
376    Wheel(events::ScrollDelta),
377    /// A key was pressed (or auto-repeated).
378    KeyDown {
379        /// The logical key.
380        key: events::Key,
381        /// Modifier keys held.
382        modifiers: events::Modifiers,
383        /// Whether this is an auto-repeat (key held down).
384        repeat: bool,
385    },
386    /// A key was released.
387    KeyUp {
388        /// The logical key.
389        key: events::Key,
390        /// Modifier keys held.
391        modifiers: events::Modifiers,
392    },
393    /// IME preedit — composition in progress.
394    ///
395    /// `text` is the current composition string. `cursor` is the byte-offset
396    /// range `(start, end)` within `text` that should be highlighted as the
397    /// cursor/selection; `None` means no explicit cursor hint.
398    ///
399    /// Note: on the egui forwarding path the cursor range is not forwarded
400    /// (egui 0.34's `ImeEvent::Preedit` only accepts a `String`).
401    ImePreedit {
402        /// Composition string being entered.
403        text: String,
404        /// Optional byte-range cursor hint within `text`.
405        cursor: Option<(usize, usize)>,
406    },
407    /// IME commit — final committed text after composition ends.
408    ///
409    /// Callers should insert `text` into the active text-input field.
410    ImeCommit(String),
411}
412
413// ── Traits ─────────────────────────────────────────────────────────────────
414
415/// Rendering context passed to every [`Widget::render`] call.
416///
417/// The three core methods ([`heading`](UiCtx::heading), [`label`](UiCtx::label),
418/// [`button`](UiCtx::button)) are **required**: every adapter implements them.
419///
420/// The remaining widget methods are **provided with default implementations**
421/// that return a `*Response` whose `supported` field is `false` (see
422/// [`response`]). This is a deliberate design choice: an adapter that has not
423/// overridden, say, [`slider`](UiCtx::slider) reports `supported == false` to
424/// the caller rather than silently rendering nothing and pretending it worked.
425/// Adapters override the subset of extended widgets they actually support; the
426/// rest degrade visibly. Callers branch on the `supported` flag to fall back.
427pub trait UiCtx {
428    /// Render a heading-sized text string.
429    fn heading(&mut self, text: &str);
430    /// Render a body-text label.
431    fn label(&mut self, text: &str);
432    /// Render a button and return the interaction state.
433    fn button(&mut self, label: &str) -> ButtonResponse;
434
435    /// Render a single-line text-input field seeded with `text`.
436    ///
437    /// Default: unsupported (`supported = false`, empty text).
438    fn text_input(&mut self, _text: &str) -> response::TextInputResponse {
439        response::TextInputResponse::unsupported()
440    }
441
442    /// Render a checkbox labelled `label` in state `checked`.
443    ///
444    /// Default: unsupported (`supported = false`).
445    fn checkbox(&mut self, _label: &str, _checked: bool) -> response::CheckboxResponse {
446        response::CheckboxResponse::unsupported()
447    }
448
449    /// Render a slider over `range` at `value`.
450    ///
451    /// Default: unsupported (`supported = false`, value `0.0`).
452    fn slider(
453        &mut self,
454        _value: f64,
455        _range: core::ops::RangeInclusive<f64>,
456    ) -> response::SliderResponse {
457        response::SliderResponse::unsupported()
458    }
459
460    /// Render a dropdown of `options` with `selected` chosen.
461    ///
462    /// Default: unsupported (`supported = false`, selection `0`).
463    fn dropdown(&mut self, _options: &[&str], _selected: usize) -> response::DropdownResponse {
464        response::DropdownResponse::unsupported()
465    }
466
467    /// Render an image identified by `uri` at an optional `size`.
468    ///
469    /// Default: unsupported (`supported = false`).
470    fn image(&mut self, _uri: &str, _size: Option<Size>) -> response::WidgetResponse {
471        response::WidgetResponse::unsupported()
472    }
473
474    /// Render a separator (horizontal/vertical rule).
475    ///
476    /// Default: unsupported (`supported = false`).
477    fn separator(&mut self) -> response::WidgetResponse {
478        response::WidgetResponse::unsupported()
479    }
480
481    /// Render empty space of `size` logical pixels along the layout axis.
482    ///
483    /// Default: unsupported (`supported = false`).
484    fn spacer(&mut self, _size: f32) -> response::WidgetResponse {
485        response::WidgetResponse::unsupported()
486    }
487
488    /// Render `content` inside a scrollable region.
489    ///
490    /// Default: unsupported (`supported = false`); the closure is **not**
491    /// invoked, so a caller can detect non-support before side effects run.
492    fn scroll_area(
493        &mut self,
494        _content: &mut dyn FnMut(&mut dyn UiCtx),
495    ) -> response::WidgetResponse {
496        response::WidgetResponse::unsupported()
497    }
498
499    /// Attach a tooltip with `text` to the most recently rendered widget.
500    ///
501    /// Default: unsupported (`supported = false`).
502    fn tooltip(&mut self, _text: &str) -> response::WidgetResponse {
503        response::WidgetResponse::unsupported()
504    }
505
506    /// Render a popup containing `content`.
507    ///
508    /// Default: unsupported (`supported = false`); the closure is not invoked.
509    fn popup(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
510        response::WidgetResponse::unsupported()
511    }
512
513    /// Render a modal dialog titled `title` containing `content`.
514    ///
515    /// Default: unsupported (`supported = false`); the closure is not invoked.
516    fn modal(
517        &mut self,
518        _title: &str,
519        _content: &mut dyn FnMut(&mut dyn UiCtx),
520    ) -> response::WidgetResponse {
521        response::WidgetResponse::unsupported()
522    }
523
524    /// Lay out `content` in a horizontal row.
525    ///
526    /// Default: unsupported; the closure is **not** invoked.
527    fn horizontal(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
528        response::WidgetResponse::unsupported()
529    }
530
531    /// Lay out `content` in a vertical column.
532    ///
533    /// Default: unsupported; the closure is **not** invoked.
534    fn vertical(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
535        response::WidgetResponse::unsupported()
536    }
537
538    /// Lay out `content` in a grid with `cols` columns.
539    ///
540    /// Default: unsupported; the closure is **not** invoked.
541    fn grid(
542        &mut self,
543        _cols: usize,
544        _content: &mut dyn FnMut(&mut dyn UiCtx),
545    ) -> response::WidgetResponse {
546        response::WidgetResponse::unsupported()
547    }
548
549    /// Render a menu bar containing `content`.
550    ///
551    /// Default: unsupported; the closure is **not** invoked.
552    fn menu_bar(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> response::WidgetResponse {
553        response::WidgetResponse::unsupported()
554    }
555
556    /// Render multi-styled text from a slice of [`RichTextSpan`]s.
557    ///
558    /// Default: unsupported (`supported = false`).
559    fn rich_text(&mut self, _spans: &[RichTextSpan]) -> response::WidgetResponse {
560        response::WidgetResponse::unsupported()
561    }
562
563    /// Render a body-text label with an explicit [`TextStyle`].
564    ///
565    /// The default implementation delegates to [`UiCtx::label`] (text is
566    /// always rendered) and ignores `_style`. Adapters that can honour rich
567    /// typography should override this method.
568    ///
569    /// Returns [`WidgetResponse::supported`] because `label` is a *required*
570    /// method — the text is guaranteed to appear even if the style is ignored.
571    fn label_styled(&mut self, text: &str, _style: TextStyle) -> response::WidgetResponse {
572        self.label(text);
573        response::WidgetResponse::supported()
574    }
575
576    /// Render a heading with an explicit [`TextStyle`].
577    ///
578    /// The default implementation delegates to [`UiCtx::heading`] (text is
579    /// always rendered) and ignores `_style`. Adapters that can honour rich
580    /// typography should override this method.
581    ///
582    /// Returns [`WidgetResponse::supported`] because `heading` is a *required*
583    /// method — the text is guaranteed to appear even if the style is ignored.
584    fn heading_styled(&mut self, text: &str, _style: TextStyle) -> response::WidgetResponse {
585        self.heading(text);
586        response::WidgetResponse::supported()
587    }
588
589    /// Mark `content` as a drag source with the given `id`.
590    ///
591    /// Default: unsupported; the closure is **not** invoked.
592    fn drag_source(
593        &mut self,
594        _id: u64,
595        _content: &mut dyn FnMut(&mut dyn UiCtx),
596    ) -> response::WidgetResponse {
597        response::WidgetResponse::unsupported()
598    }
599
600    /// Mark `content` as a drop target that accepts drags with any of the given `accept_ids`.
601    ///
602    /// Default: unsupported; the closure is **not** invoked.
603    fn drop_target(
604        &mut self,
605        _accept_ids: &[u64],
606        _content: &mut dyn FnMut(&mut dyn UiCtx),
607    ) -> response::WidgetResponse {
608        response::WidgetResponse::unsupported()
609    }
610}
611
612/// A UI widget that can render itself into a [`UiCtx`].
613pub trait Widget {
614    /// Render the widget into `ui`.
615    fn render(&mut self, ui: &mut dyn UiCtx);
616}
617
618/// A UI theme that provides a colour palette and font specification.
619pub trait Theme: Send + Sync {
620    /// Return the colour palette for this theme.
621    fn palette(&self) -> &Palette;
622    /// Return the font specification for this theme.
623    fn font(&self) -> &FontSpec;
624}
625
626/// A layout strategy that controls how children are arranged.
627pub trait Layout: Send + Sync {
628    /// Primary layout axis.
629    fn axis(&self) -> Axis;
630    /// Spacing between children in logical pixels.
631    fn spacing(&self) -> f32;
632}
633
634/// An event sink that accepts UI events for processing.
635pub trait EventSink {
636    /// Push an event into the sink.
637    fn push(&mut self, event: UiEvent);
638}
639
640// ── Error ───────────────────────────────────────────────────────────────────
641
642/// Errors emitted by the OxiUI stack.
643///
644/// This enum is `#[non_exhaustive]`: downstream `match` expressions must include
645/// a catch-all (`_ => …`) so new variants can be added without a breaking
646/// change.
647#[derive(Debug)]
648#[non_exhaustive]
649pub enum UiError {
650    /// A backend (windowing / GPU initialisation) error.
651    Backend(String),
652    /// A render-pipeline error.
653    Render(String),
654    /// A window-management error.
655    Window(String),
656    /// The requested feature or backend is not available.
657    Unsupported(String),
658    /// A layout-engine error (e.g. an unsatisfiable constraint set).
659    Layout(String),
660    /// A focus-management error (e.g. focusing a non-focusable node).
661    Focus(String),
662    /// A clipboard access error (e.g. unsupported MIME type, OS denial).
663    Clipboard(String),
664    /// A drag-and-drop protocol error (e.g. rejected payload).
665    DragDrop(String),
666    /// Any other error not covered by the above variants.
667    Other(String),
668}
669
670impl std::fmt::Display for UiError {
671    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672        match self {
673            UiError::Backend(s) => write!(f, "UI backend error: {s}"),
674            UiError::Render(s) => write!(f, "UI render error: {s}"),
675            UiError::Window(s) => write!(f, "UI window error: {s}"),
676            UiError::Unsupported(s) => write!(f, "UI unsupported: {s}"),
677            UiError::Layout(s) => write!(f, "UI layout error: {s}"),
678            UiError::Focus(s) => write!(f, "UI focus error: {s}"),
679            UiError::Clipboard(s) => write!(f, "UI clipboard error: {s}"),
680            UiError::DragDrop(s) => write!(f, "UI drag-and-drop error: {s}"),
681            UiError::Other(s) => write!(f, "UI error: {s}"),
682        }
683    }
684}
685
686impl std::error::Error for UiError {}
687
688// ── Tests ───────────────────────────────────────────────────────────────────
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn ime_preedit_event_roundtrip() {
696        let event = UiEvent::ImePreedit {
697            text: "日本語".to_string(),
698            cursor: Some((0, 9)),
699        };
700        match event {
701            UiEvent::ImePreedit { text, cursor } => {
702                assert_eq!(text, "日本語");
703                assert!(cursor.is_some());
704                let (start, end) = cursor.expect("cursor should be Some");
705                assert_eq!(start, 0);
706                assert_eq!(end, 9);
707            }
708            _ => panic!("expected ImePreedit variant"),
709        }
710    }
711
712    #[test]
713    fn ime_commit_event_roundtrip() {
714        let event = UiEvent::ImeCommit("確定".to_string());
715        match event {
716            UiEvent::ImeCommit(text) => {
717                assert_eq!(text, "確定");
718            }
719            _ => panic!("expected ImeCommit variant"),
720        }
721    }
722
723    #[test]
724    fn ime_preedit_no_cursor() {
725        let event = UiEvent::ImePreedit {
726            text: "abc".to_string(),
727            cursor: None,
728        };
729        match event {
730            UiEvent::ImePreedit { text, cursor } => {
731                assert_eq!(text, "abc");
732                assert!(cursor.is_none());
733            }
734            _ => panic!("expected ImePreedit variant"),
735        }
736    }
737
738    #[test]
739    fn font_spec_expansion_defaults_and_builders() {
740        // Legacy constructor still yields upright/no-override extras.
741        let base = FontSpec::new("Inter", 16.0, 500);
742        assert_eq!(base.style, FontStyle::Normal);
743        assert_eq!(base.letter_spacing, 0.0);
744        assert!(base.line_height.is_none());
745        assert!(base.features.is_empty());
746        assert!(!base.is_slanted());
747
748        // Builders are additive and chainable.
749        let rich = FontSpec::new("Inter", 16.0, 500)
750            .with_style(FontStyle::Italic)
751            .with_letter_spacing(0.5)
752            .with_line_height(20.0)
753            .with_feature(FontFeature::on("liga"))
754            .with_feature(FontFeature::value("ss01", 1));
755        assert!(rich.is_slanted());
756        assert_eq!(rich.letter_spacing, 0.5);
757        assert_eq!(rich.line_height, Some(20.0));
758        assert_eq!(rich.features.len(), 2);
759        assert_eq!(rich.features[0], FontFeature::on("liga"));
760
761        // Oblique carries its slant angle.
762        let obl = FontSpec::default().with_style(FontStyle::Oblique { degrees: 12.0 });
763        assert!(
764            matches!(obl.style, FontStyle::Oblique { degrees } if (degrees - 12.0).abs() < 1e-6)
765        );
766    }
767
768    #[test]
769    fn extended_uictx_defaults_report_unsupported() {
770        // A minimal adapter that overrides only the required methods must see
771        // every extended widget report supported == false by default.
772        struct BareCtx;
773        impl UiCtx for BareCtx {
774            fn heading(&mut self, _t: &str) {}
775            fn label(&mut self, _t: &str) {}
776            fn button(&mut self, _l: &str) -> ButtonResponse {
777                ButtonResponse::default()
778            }
779        }
780        let mut ui = BareCtx;
781        assert!(!ui.text_input("x").supported);
782        assert!(!ui.checkbox("c", true).supported);
783        assert!(!ui.slider(0.5, 0.0..=1.0).supported);
784        assert!(!ui.dropdown(&["a", "b"], 0).supported);
785        assert!(!ui.image("u", None).supported);
786        assert!(!ui.separator().supported);
787        assert!(!ui.spacer(8.0).supported);
788        assert!(!ui.tooltip("t").supported);
789        // Container defaults must NOT invoke their content closure.
790        let mut invoked = false;
791        let r = ui.scroll_area(&mut |_inner| invoked = true);
792        assert!(!r.supported);
793        assert!(!invoked, "unsupported scroll_area must not run content");
794        let mut popup_invoked = false;
795        assert!(!ui.popup(&mut |_| popup_invoked = true).supported);
796        assert!(!popup_invoked);
797        let mut modal_invoked = false;
798        assert!(!ui.modal("title", &mut |_| modal_invoked = true).supported);
799        assert!(!modal_invoked);
800    }
801
802    #[test]
803    fn ui_error_new_variants_display() {
804        assert!(UiError::Layout("x".into()).to_string().contains("layout"));
805        assert!(UiError::Focus("x".into()).to_string().contains("focus"));
806        assert!(UiError::Clipboard("x".into())
807            .to_string()
808            .contains("clipboard"));
809        assert!(UiError::DragDrop("x".into()).to_string().contains("drag"));
810    }
811
812    #[test]
813    fn uictx_extension_defaults_unsupported() {
814        struct Bare;
815        impl UiCtx for Bare {
816            fn heading(&mut self, _: &str) {}
817            fn label(&mut self, _: &str) {}
818            fn button(&mut self, _: &str) -> ButtonResponse {
819                ButtonResponse::default()
820            }
821        }
822        let mut b = Bare;
823        assert!(!b.horizontal(&mut |_| {}).supported);
824        assert!(!b.vertical(&mut |_| {}).supported);
825        assert!(!b.grid(2, &mut |_| {}).supported);
826        assert!(!b.menu_bar(&mut |_| {}).supported);
827        assert!(!b.rich_text(&[]).supported);
828        assert!(!b.drag_source(1, &mut |_| {}).supported);
829        assert!(!b.drop_target(&[], &mut |_| {}).supported);
830    }
831
832    #[test]
833    fn rich_text_span_builder() {
834        let span = RichTextSpan::new("Hello")
835            .bold()
836            .italic()
837            .color([255, 0, 0, 255])
838            .font_size(24.0);
839        assert!(span.bold);
840        assert!(span.italic);
841        assert_eq!(span.color, [255, 0, 0, 255]);
842        assert_eq!(span.font_size, 24.0);
843        assert_eq!(span.text, "Hello");
844    }
845}