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}