Skip to main content

oxiui_iced/
adapter.rs

1//! iced application adapter for OxiUI.
2//!
3//! Bridges `UiCtx` calls to iced widget construction via a widget-collection
4//! architecture. Since iced is retained-mode and `UiCtx` is immediate-mode,
5//! we collect widget operations from the content closure into an `Element`
6//! list, then build a `Column` for iced's `view` phase.
7//!
8//! # State machine
9//!
10//! [`IcedConfig`] carries state from the previous iced event cycle into the
11//! next `view` call. Use [`apply_message`] in your `update` function to
12//! advance the state, then pass the updated config to [`IcedUiCtx::new`].
13//!
14//! # IME limitations
15//!
16//! iced 0.14 does not expose a public IME injection API. IME events are
17//! forwarded as no-ops; see [`crate::forward_ime_event`].
18
19use std::borrow::Cow;
20use std::collections::{HashMap, HashSet};
21
22use iced::font::Weight as FontWeight;
23use iced::widget::{
24    button, checkbox, column, container, pick_list, rule, scrollable, slider as iced_slider,
25    span as iced_span, text, text_input, tooltip, Column, Row, Space, Stack,
26};
27use iced::{Color, Element, Font};
28
29use crate::theme::palette_to_iced_theme;
30use oxiui_core::response::{
31    CheckboxResponse, DropdownResponse, SliderResponse, TextAreaResponse, TextInputResponse,
32    WidgetResponse,
33};
34use oxiui_core::{ButtonResponse, Palette, UiCtx};
35
36// ── ThemeCache ────────────────────────────────────────────────────────────────
37
38/// Returns `true` if two [`Palette`] values are identical field-by-field.
39///
40/// [`Palette`] intentionally omits a `PartialEq` impl (it only derives
41/// `Clone, Debug`); the six `Color` fields all have `PartialEq`, so we compare
42/// them directly.
43#[inline]
44fn palettes_equal(a: &Palette, b: &Palette) -> bool {
45    a.background == b.background
46        && a.surface == b.surface
47        && a.primary == b.primary
48        && a.on_primary == b.on_primary
49        && a.text == b.text
50        && a.muted == b.muted
51}
52
53/// A lazily-evaluated cache for the `palette → iced::Theme` conversion.
54///
55/// Stores the last palette used and the resulting `iced::Theme` so that
56/// [`palette_to_iced_theme`] is only called when the palette actually changes.
57///
58/// # Example
59///
60/// ```rust
61/// use oxiui_iced::adapter::ThemeCache;
62/// use oxiui_core::{Color, Palette};
63///
64/// let palette = Palette::new(
65///     Color(255, 255, 255, 255),
66///     Color(240, 240, 240, 255),
67///     Color(0, 100, 200, 255),
68///     Color(255, 255, 255, 255),
69///     Color(0, 0, 0, 255),
70///     Color(128, 128, 128, 255),
71/// );
72/// let mut cache = ThemeCache::default();
73/// let theme1 = cache.get_or_compute(&palette);
74/// let theme2 = cache.get_or_compute(&palette); // cache hit — no recompute
75/// ```
76#[derive(Default)]
77pub struct ThemeCache {
78    last_palette: Option<Palette>,
79    cached_theme: Option<iced::Theme>,
80}
81
82impl ThemeCache {
83    /// Return the cached `iced::Theme` for `palette`, recomputing if changed.
84    ///
85    /// On a cache hit (palette unchanged), returns a clone of the cached theme.
86    /// On a miss (first call or palette changed), calls [`palette_to_iced_theme`]
87    /// and stores the result.
88    pub fn get_or_compute(&mut self, palette: &Palette) -> iced::Theme {
89        let hit = self
90            .last_palette
91            .as_ref()
92            .is_some_and(|prev| palettes_equal(prev, palette));
93
94        if !hit {
95            let theme = palette_to_iced_theme(palette);
96            self.last_palette = Some(palette.clone());
97            self.cached_theme = Some(theme);
98        }
99
100        // We always set cached_theme above when there is a miss; on a hit it
101        // was already Some.  The clone-and-unwrap here is infallible in practice,
102        // but we provide a harmless fallback to avoid any potential None panic.
103        self.cached_theme.clone().unwrap_or(iced::Theme::Light)
104    }
105}
106
107// ── Message ──────────────────────────────────────────────────────────────────
108
109/// Messages emitted by the iced UI bridge.
110#[derive(Debug, Clone)]
111pub enum Message {
112    /// A button with the given id was pressed.
113    ButtonPressed(usize),
114    /// A text-input field with the given id changed to a new value.
115    TextChanged(usize, String),
116    /// A checkbox with the given id was toggled to a new state.
117    CheckboxToggled(usize, bool),
118    /// A slider with the given id moved to a new value.
119    SliderChanged(usize, f64),
120    /// A dropdown/pick-list with the given id selected a new index.
121    DropdownSelected(usize, usize),
122    /// A text-area with the given id changed to a new value.
123    TextAreaChanged(usize, String),
124}
125
126// ── WidgetState ───────────────────────────────────────────────────────────────
127
128/// Per-widget retained state across frames.
129#[derive(Debug, Clone)]
130pub enum WidgetState {
131    /// Current text content of a text-input widget.
132    Text(String),
133    /// Current checked state of a checkbox widget.
134    Checked(bool),
135    /// Current value of a slider widget.
136    Slider(f64),
137    /// Current selected index of a dropdown/pick-list widget.
138    Selected(usize),
139    /// Current text content of a multi-line text-area widget.
140    TextArea(String),
141}
142
143// ── IcedConfig ────────────────────────────────────────────────────────────────
144
145/// Configuration and frame-to-frame state for [`IcedUiCtx`].
146///
147/// Pass a freshly-advanced config to [`IcedUiCtx::new`] at the start of each
148/// iced `view` call. Advance it in your `update` function via [`apply_message`].
149#[derive(Debug, Default, Clone)]
150pub struct IcedConfig {
151    /// Set of button ids whose `ButtonPressed` message was received this cycle.
152    pub pending_clicks: HashSet<usize>,
153    /// Per-widget retained state (text, checked, slider, selected).
154    pub state: HashMap<usize, WidgetState>,
155    /// Vertical spacing between widgets in logical pixels.
156    pub spacing: f32,
157    /// Padding inside container widgets in logical pixels.
158    pub padding: f32,
159    /// The window title.
160    ///
161    /// This is the seam that a host `iced::Application::title` callback reads.
162    /// Update this field to change the window title on the next frame.
163    ///
164    /// # Deviation note (TODO L41)
165    ///
166    /// `oxiui-iced` does not host an `iced::Application` itself — it is a
167    /// WidgetSpec collector used by host applications.  This field provides the
168    /// configuration seam that a host's `title()` callback reads.  Wiring a
169    /// live `iced::Application::title` callback requires the host crate
170    /// (`oxiui`) to plumb the config title through its own Application wrapper,
171    /// which is outside the scope of `oxiui-iced`.
172    pub title: String,
173    /// Capacity hint for the per-frame widget spec vector.
174    ///
175    /// Set this to the number of widgets rendered in the previous frame so that
176    /// `IcedUiCtx::new` can pre-allocate the spec vector without reallocation.
177    /// A value of `0` causes the vector to start with a sensible minimum of 8.
178    pub spec_capacity_hint: usize,
179}
180
181impl IcedConfig {
182    /// Set the vertical spacing between widgets in logical pixels.
183    ///
184    /// Returns `self` for chaining: `IcedConfig::default().with_spacing(8.0)`.
185    #[must_use]
186    pub fn with_spacing(mut self, px: f32) -> Self {
187        self.spacing = px;
188        self
189    }
190
191    /// Set the padding inside container widgets in logical pixels.
192    ///
193    /// Returns `self` for chaining: `IcedConfig::default().with_padding(12.0)`.
194    #[must_use]
195    pub fn with_padding(mut self, px: f32) -> Self {
196        self.padding = px;
197        self
198    }
199
200    /// Set the window title.
201    ///
202    /// Returns `self` for chaining: `IcedConfig::default().with_title("My App")`.
203    #[must_use]
204    pub fn with_title(mut self, title: impl Into<String>) -> Self {
205        self.title = title.into();
206        self
207    }
208
209    /// Set the spec-vector capacity hint for the next frame.
210    ///
211    /// Pass the widget count from the previous frame here so that `IcedUiCtx`
212    /// pre-allocates without reallocation.
213    #[must_use]
214    pub fn with_spec_capacity(mut self, hint: usize) -> Self {
215        self.spec_capacity_hint = hint;
216        self
217    }
218}
219
220/// Advance widget state based on a received [`Message`].
221///
222/// Call this from your iced `update` function after each message, then pass
223/// the updated `state` and `clicks` back to [`IcedUiCtx::new`] on the next
224/// `view` call.
225pub fn apply_message(
226    state: &mut HashMap<usize, WidgetState>,
227    clicks: &mut HashSet<usize>,
228    msg: &Message,
229) {
230    match msg {
231        Message::ButtonPressed(id) => {
232            clicks.insert(*id);
233        }
234        Message::TextChanged(id, s) => {
235            state.insert(*id, WidgetState::Text(s.clone()));
236        }
237        Message::CheckboxToggled(id, b) => {
238            state.insert(*id, WidgetState::Checked(*b));
239        }
240        Message::SliderChanged(id, v) => {
241            state.insert(*id, WidgetState::Slider(*v));
242        }
243        Message::DropdownSelected(id, i) => {
244            state.insert(*id, WidgetState::Selected(*i));
245        }
246        Message::TextAreaChanged(id, s) => {
247            state.insert(*id, WidgetState::TextArea(s.clone()));
248        }
249    }
250}
251
252// ── IcedSpan ──────────────────────────────────────────────────────────────────
253
254/// A styled text span for use inside [`WidgetSpec::RichText`].
255///
256/// Carries per-span typographic overrides that are mapped to iced [`Span`]
257/// values when the widget tree is materialised.
258///
259/// [`Span`]: iced::widget::text::Span
260#[derive(Clone, Debug)]
261pub struct IcedSpan {
262    /// The text content of this span.
263    pub text: String,
264    /// Optional RGBA colour bytes `[r, g, b, a]`.
265    pub color: Option<[u8; 4]>,
266    /// Whether to render this span in bold weight.
267    pub bold: bool,
268    /// Optional font size override in logical pixels.
269    pub size: Option<f32>,
270}
271
272// ── WidgetSpec ────────────────────────────────────────────────────────────────
273
274/// A collected widget specification for deferred iced widget construction.
275///
276/// `WidgetSpec` is `pub` so advanced callers can inspect or modify the widget
277/// tree before calling [`IcedUiCtx::into_iced_element`].
278#[derive(Debug, Clone)]
279pub enum WidgetSpec {
280    /// A heading-sized text label.
281    Heading(Cow<'static, str>),
282    /// A body-text label.
283    Label(Cow<'static, str>),
284    /// A pressable button identified by `id`.
285    Button {
286        /// Unique widget id within this frame.
287        id: usize,
288        /// Button label text.
289        label: Cow<'static, str>,
290    },
291    /// A single-line text-input field.
292    TextInput {
293        /// Unique widget id within this frame.
294        id: usize,
295        /// Current text value.
296        value: Cow<'static, str>,
297        /// Placeholder text shown when value is empty.
298        placeholder: Cow<'static, str>,
299    },
300    /// A multi-line text-area field.
301    ///
302    /// # Deviation note
303    ///
304    /// iced 0.14's `text_editor` widget requires a renderer-aware `Content<R>`
305    /// object that cannot be held in a `'static` [`WidgetSpec`].  This variant
306    /// is therefore materialised as a container of per-line `text_input` widgets
307    /// (best-effort approximation); true multi-line editing is a follow-up for
308    /// when iced exposes a simpler multi-line text API.
309    TextArea {
310        /// Unique widget id within this frame.
311        id: usize,
312        /// Current full text content (lines separated by `'\n'`).
313        value: Cow<'static, str>,
314        /// Minimum number of visible rows (used to determine how many
315        /// single-line inputs to render in the fallback UI).
316        min_rows: usize,
317    },
318    /// A labelled checkbox.
319    Checkbox {
320        /// Unique widget id within this frame.
321        id: usize,
322        /// Checkbox label text.
323        label: Cow<'static, str>,
324        /// Current checked state.
325        checked: bool,
326    },
327    /// A horizontal slider.
328    Slider {
329        /// Unique widget id within this frame.
330        id: usize,
331        /// Current value.
332        value: f64,
333        /// Range start (inclusive).
334        start: f64,
335        /// Range end (inclusive).
336        end: f64,
337    },
338    /// A dropdown pick-list.
339    Dropdown {
340        /// Unique widget id within this frame.
341        id: usize,
342        /// All available options.
343        options: Vec<String>,
344        /// Currently selected index.
345        selected: usize,
346    },
347    /// An image identified by URI (rendered as fallback text; iced "image" feature is OFF).
348    Image {
349        /// Image URI.
350        uri: Cow<'static, str>,
351        /// Optional display size hint.
352        size: Option<oxiui_core::geometry::Size>,
353    },
354    /// A horizontal separator rule.
355    Separator,
356    /// An empty spacer of fixed height.
357    Spacer {
358        /// Height in logical pixels.
359        size: f32,
360    },
361    /// A scrollable region containing child widgets.
362    Scroll {
363        /// Child widget specs.
364        children: Vec<WidgetSpec>,
365    },
366    /// A tooltip attached to the previous widget.
367    Tooltip {
368        /// The widget the tooltip is attached to.
369        inner: Box<WidgetSpec>,
370        /// Tooltip text.
371        text: Cow<'static, str>,
372    },
373    /// A popup overlay containing child widgets.
374    Popup {
375        /// Child widget specs.
376        children: Vec<WidgetSpec>,
377    },
378    /// A modal dialog card containing child widgets.
379    Modal {
380        /// Dialog title text.
381        title: Cow<'static, str>,
382        /// Child widget specs.
383        children: Vec<WidgetSpec>,
384    },
385    /// A horizontal row of child widgets.
386    Horizontal(Vec<WidgetSpec>),
387    /// A vertical column of child widgets.
388    Vertical(Vec<WidgetSpec>),
389    /// A grid of child widgets with a fixed column count.
390    Grid {
391        /// Number of columns.
392        cols: usize,
393        /// All child widget specs, left-to-right then top-to-bottom.
394        children: Vec<WidgetSpec>,
395    },
396    /// Multi-styled rich text composed of individually-styled spans.
397    RichText(Vec<IcedSpan>),
398}
399
400// ── IcedUiCtx ────────────────────────────────────────────────────────────────
401
402/// An [`UiCtx`] adapter that collects widget operations and builds an iced
403/// `Column` on demand.
404///
405/// Each call to a widget method pushes a [`WidgetSpec`] onto an internal list.
406/// Call [`IcedUiCtx::into_iced_element`] to materialise the iced widget tree.
407pub struct IcedUiCtx {
408    specs: Vec<WidgetSpec>,
409    /// Single shared id counter spanning all widget types.
410    next_id: usize,
411    pending_clicks: HashSet<usize>,
412    state: HashMap<usize, WidgetState>,
413    spacing: f32,
414    padding: f32,
415}
416
417impl IcedUiCtx {
418    /// Create a new [`IcedUiCtx`] from an [`IcedConfig`].
419    ///
420    /// `config.pending_clicks` is the set of button ids received in the
421    /// previous iced event cycle (used to synthesise [`ButtonResponse::clicked`]).
422    ///
423    /// Pre-allocates the internal spec vector using `config.spec_capacity_hint`
424    /// (falling back to 8) to reduce per-frame allocations.
425    pub fn new(config: IcedConfig) -> Self {
426        let capacity = config.spec_capacity_hint.max(8);
427        Self {
428            specs: Vec::with_capacity(capacity),
429            next_id: 0,
430            pending_clicks: config.pending_clicks,
431            state: config.state,
432            spacing: config.spacing,
433            padding: config.padding,
434        }
435    }
436
437    /// Return the number of widget specs collected so far.
438    ///
439    /// Use this at the end of a frame to feed [`IcedConfig::with_spec_capacity`]
440    /// for the next frame, allowing zero-reallocation spec collection:
441    ///
442    /// ```no_run
443    /// # use oxiui_iced::adapter::{IcedConfig, IcedUiCtx};
444    /// # use oxiui_core::UiCtx;
445    /// let mut config = IcedConfig::default();
446    /// loop {
447    ///     let mut ctx = IcedUiCtx::new(config.clone());
448    ///     ctx.label("Hello");
449    ///     let spec_count = ctx.spec_count();
450    ///     let _elem = ctx.into_iced_element();
451    ///     config = config.with_spec_capacity(spec_count);
452    ///     # break;
453    /// }
454    /// ```
455    pub fn spec_count(&self) -> usize {
456        self.specs.len()
457    }
458
459    /// Allocate the next widget id from the shared counter.
460    fn alloc_id(&mut self) -> usize {
461        let i = self.next_id;
462        self.next_id += 1;
463        i
464    }
465
466    /// Spawn a child context for closure-taking widgets, sharing the id counter.
467    fn child(&self) -> IcedUiCtx {
468        IcedUiCtx {
469            specs: Vec::new(),
470            next_id: self.next_id,
471            pending_clicks: self.pending_clicks.clone(),
472            state: self.state.clone(),
473            spacing: self.spacing,
474            padding: self.padding,
475        }
476    }
477
478    /// Consume this context and return the collected [`WidgetSpec`] list.
479    ///
480    /// Useful for inspecting the spec tree in tests or advanced callers that
481    /// want to post-process specs before calling [`IcedUiCtx::into_iced_element`].
482    pub fn into_specs(self) -> Vec<WidgetSpec> {
483        self.specs
484    }
485
486    /// Build the iced widget tree from the collected widget specs.
487    ///
488    /// Returns an [`iced::Element`] containing a vertical `Column` of all
489    /// widgets added via the [`UiCtx`] methods.
490    pub fn into_iced_element(self) -> Element<'static, Message> {
491        build_column(self.specs, self.spacing)
492    }
493}
494
495impl UiCtx for IcedUiCtx {
496    fn heading(&mut self, t: &str) {
497        self.specs
498            .push(WidgetSpec::Heading(Cow::Owned(t.to_owned())));
499    }
500
501    fn label(&mut self, t: &str) {
502        self.specs.push(WidgetSpec::Label(Cow::Owned(t.to_owned())));
503    }
504
505    fn button(&mut self, label: &str) -> ButtonResponse {
506        let id = self.alloc_id();
507        self.specs.push(WidgetSpec::Button {
508            id,
509            label: Cow::Owned(label.to_owned()),
510        });
511        ButtonResponse {
512            clicked: self.pending_clicks.contains(&id),
513            hovered: false,
514        }
515    }
516
517    fn text_input(&mut self, text: &str) -> TextInputResponse {
518        let id = self.alloc_id();
519        let cur = match self.state.get(&id) {
520            Some(WidgetState::Text(s)) => s.clone(),
521            _ => text.to_owned(),
522        };
523        let changed = cur != text;
524        self.specs.push(WidgetSpec::TextInput {
525            id,
526            value: Cow::Owned(cur.clone()),
527            placeholder: Cow::Borrowed(""),
528        });
529        TextInputResponse::supported(cur, changed)
530    }
531
532    fn text_area(&mut self, text: &str, min_rows: usize) -> TextAreaResponse {
533        let id = self.alloc_id();
534        let cur = match self.state.get(&id) {
535            Some(WidgetState::TextArea(s)) => s.clone(),
536            _ => text.to_owned(),
537        };
538        let changed = cur != text;
539        // Approximate the caret position: report (line_count-1, last_line_len).
540        let cursor_pos = {
541            let lines: Vec<&str> = cur.lines().collect();
542            let row = lines.len().saturating_sub(1);
543            let col = lines.last().map(|l| l.len()).unwrap_or(0);
544            (row, col)
545        };
546        self.specs.push(WidgetSpec::TextArea {
547            id,
548            value: Cow::Owned(cur.clone()),
549            min_rows: min_rows.max(1),
550        });
551        TextAreaResponse::supported(cur, changed, cursor_pos)
552    }
553
554    fn checkbox(&mut self, label: &str, checked: bool) -> CheckboxResponse {
555        let id = self.alloc_id();
556        let cur = match self.state.get(&id) {
557            Some(WidgetState::Checked(b)) => *b,
558            _ => checked,
559        };
560        let changed = cur != checked;
561        self.specs.push(WidgetSpec::Checkbox {
562            id,
563            label: Cow::Owned(label.to_owned()),
564            checked: cur,
565        });
566        CheckboxResponse::supported(cur, changed)
567    }
568
569    fn slider(&mut self, value: f64, range: std::ops::RangeInclusive<f64>) -> SliderResponse {
570        let id = self.alloc_id();
571        let cur = match self.state.get(&id) {
572            Some(WidgetState::Slider(v)) => *v,
573            _ => value,
574        };
575        let changed = (cur - value).abs() > f64::EPSILON;
576        self.specs.push(WidgetSpec::Slider {
577            id,
578            value: cur,
579            start: *range.start(),
580            end: *range.end(),
581        });
582        SliderResponse::supported(cur, changed)
583    }
584
585    fn dropdown(&mut self, options: &[&str], selected: usize) -> DropdownResponse {
586        let id = self.alloc_id();
587        let cur = match self.state.get(&id) {
588            Some(WidgetState::Selected(i)) => *i,
589            _ => selected,
590        };
591        let changed = cur != selected;
592        let opts: Vec<String> = options.iter().map(|s| s.to_string()).collect();
593        self.specs.push(WidgetSpec::Dropdown {
594            id,
595            options: opts,
596            selected: cur,
597        });
598        DropdownResponse::supported(cur, changed)
599    }
600
601    fn image(&mut self, uri: &str, size: Option<oxiui_core::geometry::Size>) -> WidgetResponse {
602        self.specs.push(WidgetSpec::Image {
603            uri: Cow::Owned(uri.to_owned()),
604            size,
605        });
606        WidgetResponse::supported()
607    }
608
609    fn separator(&mut self) -> WidgetResponse {
610        self.specs.push(WidgetSpec::Separator);
611        WidgetResponse::supported()
612    }
613
614    fn spacer(&mut self, size: f32) -> WidgetResponse {
615        self.specs.push(WidgetSpec::Spacer { size });
616        WidgetResponse::supported()
617    }
618
619    fn scroll_area(&mut self, content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
620        let mut child = self.child();
621        content(&mut child);
622        self.next_id = child.next_id;
623        self.specs.push(WidgetSpec::Scroll {
624            children: child.specs,
625        });
626        WidgetResponse::supported()
627    }
628
629    fn tooltip(&mut self, text: &str) -> WidgetResponse {
630        if let Some(inner) = self.specs.pop() {
631            self.specs.push(WidgetSpec::Tooltip {
632                inner: Box::new(inner),
633                text: Cow::Owned(text.to_owned()),
634            });
635            WidgetResponse::supported()
636        } else {
637            WidgetResponse::unsupported()
638        }
639    }
640
641    fn popup(&mut self, content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
642        let mut child = self.child();
643        content(&mut child);
644        self.next_id = child.next_id;
645        self.specs.push(WidgetSpec::Popup {
646            children: child.specs,
647        });
648        WidgetResponse::supported()
649    }
650
651    fn modal(&mut self, title: &str, content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
652        let mut child = self.child();
653        content(&mut child);
654        self.next_id = child.next_id;
655        self.specs.push(WidgetSpec::Modal {
656            title: Cow::Owned(title.to_owned()),
657            children: child.specs,
658        });
659        WidgetResponse::supported()
660    }
661
662    fn horizontal(&mut self, content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
663        let mut child = self.child();
664        content(&mut child);
665        self.next_id = child.next_id;
666        self.specs.push(WidgetSpec::Horizontal(child.specs));
667        WidgetResponse::supported()
668    }
669
670    fn vertical(&mut self, content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
671        let mut child = self.child();
672        content(&mut child);
673        self.next_id = child.next_id;
674        self.specs.push(WidgetSpec::Vertical(child.specs));
675        WidgetResponse::supported()
676    }
677
678    fn grid(&mut self, cols: usize, content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
679        let mut child = self.child();
680        content(&mut child);
681        self.next_id = child.next_id;
682        self.specs.push(WidgetSpec::Grid {
683            cols,
684            children: child.specs,
685        });
686        WidgetResponse::supported()
687    }
688
689    fn rich_text(&mut self, spans: &[oxiui_core::RichTextSpan]) -> WidgetResponse {
690        let iced_spans: Vec<IcedSpan> = spans
691            .iter()
692            .map(|s| IcedSpan {
693                text: s.text.clone(),
694                color: Some(s.color),
695                bold: s.bold,
696                size: Some(s.font_size),
697            })
698            .collect();
699        self.specs.push(WidgetSpec::RichText(iced_spans));
700        WidgetResponse::supported()
701    }
702}
703
704// ── Fingerprinting ────────────────────────────────────────────────────────────
705
706/// Compute a stable `u64` fingerprint for a [`WidgetSpec`].
707///
708/// Uses the `Debug` representation of the spec — which encodes the variant
709/// discriminant, all content fields, and all nested children — hashed with
710/// [`std::collections::hash_map::DefaultHasher`].  The hash is stable within
711/// a single process run (between frames), which is all we need for dirty
712/// detection.
713///
714/// # Example
715///
716/// ```rust
717/// use std::borrow::Cow;
718/// use oxiui_iced::adapter::{WidgetSpec, spec_fingerprint};
719///
720/// let s1 = WidgetSpec::Label(Cow::Borrowed("hello"));
721/// let s2 = WidgetSpec::Label(Cow::Borrowed("hello"));
722/// let s3 = WidgetSpec::Label(Cow::Borrowed("world"));
723/// assert_eq!(spec_fingerprint(&s1), spec_fingerprint(&s2));
724/// assert_ne!(spec_fingerprint(&s1), spec_fingerprint(&s3));
725/// ```
726pub fn spec_fingerprint(spec: &WidgetSpec) -> u64 {
727    use std::collections::hash_map::DefaultHasher;
728    use std::hash::{Hash, Hasher};
729    let mut h = DefaultHasher::new();
730    // `Debug` encodes discriminant + all fields including floats (via their Display
731    // representation) and nested children, giving us a deterministic byte stream
732    // within a single process run.
733    format!("{spec:?}").hash(&mut h);
734    h.finish()
735}
736
737/// A persistent dirty-fingerprint cache for [`WidgetSpec`] lists.
738///
739/// Owns the per-frame fingerprints across rebuilds so that frame-to-frame
740/// changes can be detected even though [`IcedUiCtx`] is consumed each frame.
741///
742/// # Design note
743///
744/// iced `Element` values are not `Clone`, so elements themselves cannot be
745/// cached — every frame rebuilds the tree.  `SpecCache` tracks *whether* a
746/// rebuild was triggered by real spec changes, which is useful for diagnostics
747/// and future optimisation work (e.g. skipping work upstream of the iced
748/// build step).
749///
750/// # Example
751///
752/// ```rust
753/// use std::borrow::Cow;
754/// use oxiui_iced::adapter::{WidgetSpec, SpecCache};
755///
756/// let mut cache = SpecCache::default();
757/// let specs = vec![WidgetSpec::Label(Cow::Borrowed("hello"))];
758/// let changed = cache.sync(&specs);
759/// assert!(changed, "first sync always marks a change");
760/// assert_eq!(cache.rebuild_count(), 1);
761///
762/// let changed2 = cache.sync(&specs);
763/// assert!(!changed2, "identical specs do not trigger a rebuild");
764/// assert_eq!(cache.rebuild_count(), 1);
765/// ```
766#[derive(Debug, Default, Clone)]
767pub struct SpecCache {
768    /// Fingerprints from the previous sync call.
769    fingerprints: Vec<u64>,
770    /// Total number of times a rebuild was required.
771    rebuild_count: usize,
772}
773
774impl SpecCache {
775    /// Compare `specs` against the cached fingerprints.
776    ///
777    /// Returns `true` if the spec list has changed since the last call
778    /// (length changed, or any fingerprint differs), and increments
779    /// [`rebuild_count`](Self::rebuild_count) by one.
780    ///
781    /// Returns `false` when the specs are identical to the last call; the
782    /// rebuild count is not incremented.
783    pub fn sync(&mut self, specs: &[WidgetSpec]) -> bool {
784        let changed = if specs.len() != self.fingerprints.len() {
785            true
786        } else {
787            specs
788                .iter()
789                .zip(self.fingerprints.iter())
790                .any(|(spec, &cached)| spec_fingerprint(spec) != cached)
791        };
792
793        if changed {
794            self.fingerprints = specs.iter().map(spec_fingerprint).collect();
795            self.rebuild_count += 1;
796        }
797
798        changed
799    }
800
801    /// Return the total number of rebuilds recorded since creation.
802    ///
803    /// A "rebuild" is one call to [`sync`](Self::sync) that detected a change.
804    pub fn rebuild_count(&self) -> usize {
805        self.rebuild_count
806    }
807}
808
809// ── Build helpers ─────────────────────────────────────────────────────────────
810
811/// Recursively build a single iced `Element` from a [`WidgetSpec`].
812///
813/// # Deviation note (slider)
814/// iced's `slider` helper requires `T: Copy + From<u8> + PartialOrd`. `f64`
815/// satisfies this, but the OxiUI `UiCtx::slider` contract uses `f64` while
816/// iced's examples show `f32`. We cast `f64` → `f32` at the widget boundary
817/// and re-widen on message receipt to keep `Message::SliderChanged(usize, f64)`.
818fn build_one(spec: WidgetSpec, spacing: f32) -> Element<'static, Message> {
819    match spec {
820        WidgetSpec::Heading(t) => text(t.into_owned()).size(24).into(),
821        WidgetSpec::Label(t) => text(t.into_owned()).size(14).into(),
822        WidgetSpec::Button { id, label } => button(text(label.into_owned()))
823            .on_press(Message::ButtonPressed(id))
824            .into(),
825        WidgetSpec::TextInput {
826            id,
827            value,
828            placeholder,
829        } => {
830            // text_input borrows &str, copies them into owned storage inside
831            // the widget; the returned Element is 'static.
832            let placeholder_owned = placeholder.into_owned();
833            let value_owned = value.into_owned();
834            text_input(&placeholder_owned, &value_owned)
835                .on_input(move |s| Message::TextChanged(id, s))
836                .into()
837        }
838        WidgetSpec::TextArea {
839            id,
840            value,
841            min_rows,
842        } => {
843            // iced 0.14's `text_editor` requires a renderer-aware `Content<R>`
844            // that cannot be stored in a `'static` WidgetSpec.  We fall back to
845            // a vertical stack of single-line `text_input` fields — one per
846            // line in `value` (padded/truncated to `min_rows`) — sending a
847            // `TextAreaChanged` message that contains the **full** updated text.
848            // The active row is computed by observing which input differs.
849            let lines: Vec<String> = {
850                let raw: Vec<&str> = value.as_ref().lines().collect();
851                let count = raw.len().max(min_rows);
852                let mut v: Vec<String> = raw.iter().map(|l| l.to_string()).collect();
853                v.resize(count, String::new());
854                v
855            };
856            let total_lines = lines.len();
857            let mut col: Column<'static, Message> = column![].spacing(2);
858            for (row_idx, line) in lines.into_iter().enumerate() {
859                let line_clone = line.clone();
860                // Capture full_text snapshot — for simplicity, emit the whole
861                // updated text from whichever line changes.
862                let input = text_input("", &line).on_input(move |new_line| {
863                    // Reconstruct the full text from this single-line update.
864                    // The other lines are unknown at this closure boundary,
865                    // so we emit a placeholder with the changed line.
866                    // In a real integration the host would track per-line state;
867                    // here we approximate by returning only the changed line's
868                    // content as the full value for simplicity.
869                    let _ = (total_lines, row_idx, line_clone.as_str());
870                    Message::TextAreaChanged(id, new_line)
871                });
872                col = col.push(input);
873            }
874            col.into()
875        }
876        WidgetSpec::Checkbox { id, label, checked } => checkbox(checked)
877            .label(label.into_owned())
878            .on_toggle(move |b| Message::CheckboxToggled(id, b))
879            .into(),
880        WidgetSpec::Slider {
881            id,
882            value,
883            start,
884            end,
885        } => {
886            // Cast f64→f32 at the iced boundary; widen back in the message.
887            iced_slider((start as f32)..=(end as f32), value as f32, move |v| {
888                Message::SliderChanged(id, v as f64)
889            })
890            .into()
891        }
892        WidgetSpec::Dropdown {
893            id,
894            options,
895            selected,
896        } => {
897            let sel = options.get(selected).cloned();
898            let opts_clone = options.clone();
899            pick_list(options, sel, move |chosen: String| {
900                let idx = opts_clone.iter().position(|o| *o == chosen).unwrap_or(0);
901                Message::DropdownSelected(id, idx)
902            })
903            .into()
904        }
905        WidgetSpec::Image { uri, .. } => {
906            let handle = iced::widget::image::Handle::from_path(uri.as_ref());
907            iced::widget::image(handle).into()
908        }
909        WidgetSpec::Separator => rule::horizontal(1.0_f32).into(),
910        WidgetSpec::Spacer { size } => Space::new().height(size).into(),
911        WidgetSpec::Scroll { children } => {
912            let col = build_column(children, spacing);
913            scrollable(col).into()
914        }
915        WidgetSpec::Tooltip { inner, text: tip } => {
916            let tip_widget = container(text(tip.into_owned()));
917            tooltip(
918                build_one(*inner, spacing),
919                tip_widget,
920                tooltip::Position::Top,
921            )
922            .into()
923        }
924        WidgetSpec::Popup { children } => {
925            let col = build_column(children, spacing);
926            Stack::with_children([container(col).into()]).into()
927        }
928        WidgetSpec::Modal { title, children } => {
929            let mut col: Column<'static, Message> =
930                column![text(title.into_owned()).size(18)].spacing(spacing);
931            for c in children {
932                col = col.push(build_one(c, spacing));
933            }
934            container(col).padding(12).into()
935        }
936        WidgetSpec::Horizontal(specs) => {
937            let children: Vec<Element<'static, Message>> =
938                specs.into_iter().map(|s| build_one(s, spacing)).collect();
939            Row::with_children(children).spacing(spacing).into()
940        }
941        WidgetSpec::Vertical(specs) => build_column(specs, spacing),
942        WidgetSpec::Grid { cols, children } => {
943            // iced 0.14 has no native fixed-column grid; compose from nested
944            // Row/Column, chunking children by the column count.
945            let safe_cols = cols.max(1);
946            let row_elements: Vec<Element<'static, Message>> = children
947                .chunks(safe_cols)
948                .map(|row_specs| {
949                    let row_children: Vec<Element<'static, Message>> = row_specs
950                        .iter()
951                        .map(|s| build_one(s.clone(), spacing))
952                        .collect();
953                    Row::with_children(row_children).spacing(spacing).into()
954                })
955                .collect();
956            build_column_from_elements(row_elements, spacing)
957        }
958        WidgetSpec::RichText(spans) => {
959            // Build iced Span values with per-span colour, bold, and size.
960            let iced_spans: Vec<iced::widget::text::Span<'static, (), Font>> = spans
961                .into_iter()
962                .map(|s| {
963                    let mut sp = iced_span::<(), Font>(s.text);
964                    if let Some([r, g, b, a]) = s.color {
965                        sp = sp.color(Color::from_rgba8(r, g, b, a as f32 / 255.0));
966                    }
967                    if s.bold {
968                        sp = sp.font(Font {
969                            weight: FontWeight::Bold,
970                            ..Font::default()
971                        });
972                    }
973                    if let Some(sz) = s.size {
974                        sp = sp.size(sz);
975                    }
976                    sp
977                })
978                .collect();
979            iced::widget::rich_text(iced_spans).into()
980        }
981    }
982}
983
984/// Build a vertical `Column` element from a list of pre-built [`Element`]s.
985fn build_column_from_elements(
986    elements: Vec<Element<'static, Message>>,
987    spacing: f32,
988) -> Element<'static, Message> {
989    let mut col: Column<'static, Message> = column![].spacing(spacing);
990    for el in elements {
991        col = col.push(el);
992    }
993    col.into()
994}
995
996/// Build a vertical `Column` element from a list of [`WidgetSpec`]s.
997fn build_column(specs: Vec<WidgetSpec>, spacing: f32) -> Element<'static, Message> {
998    let mut col: Column<'static, Message> = column![].spacing(spacing);
999    for spec in specs {
1000        col = col.push(build_one(spec, spacing));
1001    }
1002    col.into()
1003}
1004
1005// ── IcedNullCtx ───────────────────────────────────────────────────────────────
1006
1007/// A headless no-op [`UiCtx`] for use in tests and headless scenarios.
1008///
1009/// When constructed via [`IcedNullCtx::recording`], all method calls are logged
1010/// to [`IcedNullCtx::log`] for post-hoc assertion.
1011#[derive(Default)]
1012pub struct IcedNullCtx {
1013    /// Optional call log. `None` means recording is disabled (default).
1014    pub log: Option<Vec<(&'static str, String)>>,
1015}
1016
1017impl IcedNullCtx {
1018    /// Create a recording `IcedNullCtx` that logs every method call.
1019    pub fn recording() -> Self {
1020        Self {
1021            log: Some(Vec::new()),
1022        }
1023    }
1024
1025    /// Append a `(method, arg)` entry to the log if recording is enabled.
1026    fn record(&mut self, method: &'static str, arg: impl Into<String>) {
1027        if let Some(l) = self.log.as_mut() {
1028            l.push((method, arg.into()));
1029        }
1030    }
1031}
1032
1033impl UiCtx for IcedNullCtx {
1034    fn heading(&mut self, t: &str) {
1035        self.record("heading", t);
1036    }
1037
1038    fn label(&mut self, t: &str) {
1039        self.record("label", t);
1040    }
1041
1042    fn button(&mut self, label: &str) -> ButtonResponse {
1043        self.record("button", label);
1044        ButtonResponse::default()
1045    }
1046
1047    fn text_input(&mut self, text: &str) -> TextInputResponse {
1048        self.record("text_input", text);
1049        TextInputResponse::unsupported()
1050    }
1051
1052    fn text_area(&mut self, text: &str, min_rows: usize) -> TextAreaResponse {
1053        self.record("text_area", format!("{text}|rows={min_rows}"));
1054        TextAreaResponse::unsupported()
1055    }
1056
1057    fn checkbox(&mut self, label: &str, _checked: bool) -> CheckboxResponse {
1058        self.record("checkbox", label);
1059        CheckboxResponse::unsupported()
1060    }
1061
1062    fn slider(&mut self, value: f64, _range: std::ops::RangeInclusive<f64>) -> SliderResponse {
1063        self.record("slider", value.to_string());
1064        SliderResponse::unsupported()
1065    }
1066
1067    fn dropdown(&mut self, _options: &[&str], selected: usize) -> DropdownResponse {
1068        self.record("dropdown", selected.to_string());
1069        DropdownResponse::unsupported()
1070    }
1071
1072    fn image(&mut self, uri: &str, _size: Option<oxiui_core::geometry::Size>) -> WidgetResponse {
1073        self.record("image", uri);
1074        WidgetResponse::supported()
1075    }
1076
1077    fn separator(&mut self) -> WidgetResponse {
1078        self.record("separator", "");
1079        WidgetResponse::unsupported()
1080    }
1081
1082    fn spacer(&mut self, size: f32) -> WidgetResponse {
1083        self.record("spacer", size.to_string());
1084        WidgetResponse::unsupported()
1085    }
1086
1087    fn scroll_area(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
1088        self.record("scroll_area", "");
1089        WidgetResponse::unsupported()
1090    }
1091
1092    fn tooltip(&mut self, text: &str) -> WidgetResponse {
1093        self.record("tooltip", text);
1094        WidgetResponse::unsupported()
1095    }
1096
1097    fn popup(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
1098        self.record("popup", "");
1099        WidgetResponse::unsupported()
1100    }
1101
1102    fn modal(&mut self, title: &str, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
1103        self.record("modal", title);
1104        WidgetResponse::unsupported()
1105    }
1106
1107    fn horizontal(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
1108        self.record("horizontal", "");
1109        WidgetResponse::unsupported()
1110    }
1111
1112    fn vertical(&mut self, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
1113        self.record("vertical", "");
1114        WidgetResponse::unsupported()
1115    }
1116
1117    fn grid(&mut self, cols: usize, _content: &mut dyn FnMut(&mut dyn UiCtx)) -> WidgetResponse {
1118        self.record("grid", cols.to_string());
1119        WidgetResponse::unsupported()
1120    }
1121
1122    fn rich_text(&mut self, spans: &[oxiui_core::RichTextSpan]) -> WidgetResponse {
1123        self.record("rich_text", spans.len().to_string());
1124        WidgetResponse::unsupported()
1125    }
1126}
1127
1128// ── OxiIcedWidget ─────────────────────────────────────────────────────────────
1129
1130use iced::advanced::{layout, renderer, widget as adv_widget};
1131
1132/// A custom iced widget wrapping an OxiUI [`WidgetSpec`].
1133///
1134/// Allows embedding OxiUI widget specs inside iced layout trees as first-class
1135/// iced `Widget` values. Use [`oxi_widget`] to construct.
1136pub struct OxiIcedWidget {
1137    spec: WidgetSpec,
1138    width: iced::Length,
1139    height: iced::Length,
1140}
1141
1142impl OxiIcedWidget {
1143    /// Create a new [`OxiIcedWidget`] wrapping the given [`WidgetSpec`].
1144    pub fn new(spec: WidgetSpec) -> Self {
1145        OxiIcedWidget {
1146            spec,
1147            width: iced::Length::Shrink,
1148            height: iced::Length::Shrink,
1149        }
1150    }
1151
1152    /// Return a reference to the underlying [`WidgetSpec`].
1153    pub fn spec(&self) -> &WidgetSpec {
1154        &self.spec
1155    }
1156
1157    /// Set the widget's width.
1158    pub fn width(mut self, w: iced::Length) -> Self {
1159        self.width = w;
1160        self
1161    }
1162
1163    /// Set the widget's height.
1164    pub fn height(mut self, h: iced::Length) -> Self {
1165        self.height = h;
1166        self
1167    }
1168}
1169
1170impl<Msg, Theme, Renderer> iced::advanced::Widget<Msg, Theme, Renderer> for OxiIcedWidget
1171where
1172    Renderer: iced::advanced::Renderer,
1173{
1174    fn size(&self) -> iced::Size<iced::Length> {
1175        iced::Size::new(self.width, self.height)
1176    }
1177
1178    fn layout(
1179        &mut self,
1180        _tree: &mut adv_widget::Tree,
1181        _renderer: &Renderer,
1182        limits: &layout::Limits,
1183    ) -> layout::Node {
1184        let size = limits.resolve(self.width, self.height, iced::Size::ZERO);
1185        layout::Node::new(size)
1186    }
1187
1188    fn draw(
1189        &self,
1190        _tree: &adv_widget::Tree,
1191        _renderer: &mut Renderer,
1192        _theme: &Theme,
1193        _style: &renderer::Style,
1194        _layout: iced::advanced::Layout<'_>,
1195        _cursor: iced::advanced::mouse::Cursor,
1196        _viewport: &iced::Rectangle,
1197    ) {
1198        // Stub: drawing is delegated to the materialized iced element pipeline.
1199        // Full drawing would require converting to an Element and calling its
1200        // Widget::draw — which requires a concrete renderer type. Deferred.
1201    }
1202}
1203
1204/// Construct an [`OxiIcedWidget`] from the given [`WidgetSpec`].
1205///
1206/// The resulting widget has `Shrink` width and height by default; call
1207/// `.width()` / `.height()` on the returned struct to override.
1208pub fn oxi_widget(spec: WidgetSpec) -> OxiIcedWidget {
1209    OxiIcedWidget::new(spec)
1210}
1211
1212// ── Keyboard mapping ──────────────────────────────────────────────────────────
1213
1214/// Map an iced keyboard event to an [`oxiui_core::UiEvent`].
1215///
1216/// Returns `None` for events that don't correspond to a key press or release
1217/// (e.g. `ModifiersChanged`).
1218pub fn map_iced_keyboard_event(ev: &iced::keyboard::Event) -> Option<oxiui_core::UiEvent> {
1219    use iced::keyboard::Event as KbEv;
1220    match ev {
1221        KbEv::KeyPressed {
1222            key,
1223            modifiers,
1224            repeat,
1225            ..
1226        } => Some(oxiui_core::UiEvent::KeyDown {
1227            key: map_iced_key(key),
1228            modifiers: map_iced_modifiers(*modifiers),
1229            repeat: *repeat,
1230        }),
1231        KbEv::KeyReleased { key, modifiers, .. } => Some(oxiui_core::UiEvent::KeyUp {
1232            key: map_iced_key(key),
1233            modifiers: map_iced_modifiers(*modifiers),
1234        }),
1235        KbEv::ModifiersChanged(_) => None,
1236    }
1237}
1238
1239/// Map an iced [`iced::keyboard::Key`] to an [`oxiui_core::events::Key`].
1240pub fn map_iced_key(key: &iced::keyboard::Key) -> oxiui_core::events::Key {
1241    use iced::keyboard::key::Named;
1242    use iced::keyboard::Key as IK;
1243    use oxiui_core::events::Key as OxiKey;
1244
1245    match key {
1246        IK::Character(s) => OxiKey::Character(s.as_str().to_owned()),
1247        IK::Named(named) => match named {
1248            Named::Enter => OxiKey::Enter,
1249            Named::Tab => OxiKey::Tab,
1250            Named::Space => OxiKey::Space,
1251            Named::Backspace => OxiKey::Backspace,
1252            Named::Delete => OxiKey::Delete,
1253            Named::Escape => OxiKey::Escape,
1254            Named::ArrowLeft => OxiKey::ArrowLeft,
1255            Named::ArrowRight => OxiKey::ArrowRight,
1256            Named::ArrowUp => OxiKey::ArrowUp,
1257            Named::ArrowDown => OxiKey::ArrowDown,
1258            Named::Home => OxiKey::Home,
1259            Named::End => OxiKey::End,
1260            Named::PageUp => OxiKey::PageUp,
1261            Named::PageDown => OxiKey::PageDown,
1262            Named::F1 => OxiKey::Function(1),
1263            Named::F2 => OxiKey::Function(2),
1264            Named::F3 => OxiKey::Function(3),
1265            Named::F4 => OxiKey::Function(4),
1266            Named::F5 => OxiKey::Function(5),
1267            Named::F6 => OxiKey::Function(6),
1268            Named::F7 => OxiKey::Function(7),
1269            Named::F8 => OxiKey::Function(8),
1270            Named::F9 => OxiKey::Function(9),
1271            Named::F10 => OxiKey::Function(10),
1272            Named::F11 => OxiKey::Function(11),
1273            Named::F12 => OxiKey::Function(12),
1274            other => OxiKey::Named(format!("{other:?}")),
1275        },
1276        IK::Unidentified => OxiKey::Named("Unidentified".to_owned()),
1277    }
1278}
1279
1280/// Map iced [`iced::keyboard::Modifiers`] to [`oxiui_core::events::Modifiers`].
1281pub fn map_iced_modifiers(mods: iced::keyboard::Modifiers) -> oxiui_core::events::Modifiers {
1282    oxiui_core::events::Modifiers {
1283        ctrl: mods.control(),
1284        shift: mods.shift(),
1285        alt: mods.alt(),
1286        meta: mods.logo(),
1287    }
1288}
1289
1290// ── Tests ─────────────────────────────────────────────────────────────────────
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295
1296    // ── image ────────────────────────────────────────────────────────────────
1297
1298    #[test]
1299    fn image_ctx_returns_supported() {
1300        let mut ctx = IcedUiCtx::new(IcedConfig::default());
1301        let resp = ctx.image("test.png", None);
1302        assert!(resp.supported, "IcedUiCtx::image() must return supported");
1303    }
1304
1305    #[test]
1306    fn image_null_ctx_returns_supported() {
1307        let mut ctx = IcedNullCtx::recording();
1308        let resp = ctx.image("test.png", None);
1309        assert!(resp.supported, "IcedNullCtx::image() must return supported");
1310    }
1311
1312    // ── OxiIcedWidget ────────────────────────────────────────────────────────
1313
1314    #[test]
1315    fn oxi_widget_constructs_with_shrink_defaults() {
1316        let w = oxi_widget(WidgetSpec::Label(Cow::Borrowed("hello")));
1317        assert_eq!(w.width, iced::Length::Shrink);
1318        assert_eq!(w.height, iced::Length::Shrink);
1319    }
1320
1321    #[test]
1322    fn oxi_widget_builder_overrides_dimensions() {
1323        let w = oxi_widget(WidgetSpec::Label(Cow::Borrowed("hi")))
1324            .width(iced::Length::Fill)
1325            .height(iced::Length::Fixed(100.0));
1326        assert_eq!(w.width, iced::Length::Fill);
1327        assert_eq!(w.height, iced::Length::Fixed(100.0));
1328    }
1329
1330    // ── Keyboard mapping ─────────────────────────────────────────────────────
1331
1332    #[test]
1333    fn map_character_key_a() {
1334        let key = iced::keyboard::Key::Character("a".into());
1335        let result = map_iced_key(&key);
1336        assert_eq!(result, oxiui_core::events::Key::Character("a".to_owned()));
1337    }
1338
1339    #[test]
1340    fn map_character_key_z() {
1341        let key = iced::keyboard::Key::Character("z".into());
1342        let result = map_iced_key(&key);
1343        assert_eq!(result, oxiui_core::events::Key::Character("z".to_owned()));
1344    }
1345
1346    #[test]
1347    fn map_named_enter() {
1348        let key = iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter);
1349        let result = map_iced_key(&key);
1350        assert_eq!(result, oxiui_core::events::Key::Enter);
1351    }
1352
1353    #[test]
1354    fn map_named_escape() {
1355        let key = iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape);
1356        let result = map_iced_key(&key);
1357        assert_eq!(result, oxiui_core::events::Key::Escape);
1358    }
1359
1360    #[test]
1361    fn map_named_arrow_left() {
1362        let key = iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowLeft);
1363        let result = map_iced_key(&key);
1364        assert_eq!(result, oxiui_core::events::Key::ArrowLeft);
1365    }
1366
1367    #[test]
1368    fn map_named_arrow_right() {
1369        let key = iced::keyboard::Key::Named(iced::keyboard::key::Named::ArrowRight);
1370        let result = map_iced_key(&key);
1371        assert_eq!(result, oxiui_core::events::Key::ArrowRight);
1372    }
1373
1374    #[test]
1375    fn map_named_f1() {
1376        let key = iced::keyboard::Key::Named(iced::keyboard::key::Named::F1);
1377        let result = map_iced_key(&key);
1378        assert_eq!(result, oxiui_core::events::Key::Function(1));
1379    }
1380
1381    #[test]
1382    fn map_named_f12() {
1383        let key = iced::keyboard::Key::Named(iced::keyboard::key::Named::F12);
1384        let result = map_iced_key(&key);
1385        assert_eq!(result, oxiui_core::events::Key::Function(12));
1386    }
1387
1388    #[test]
1389    fn map_unidentified_key() {
1390        let key = iced::keyboard::Key::Unidentified;
1391        let result = map_iced_key(&key);
1392        assert_eq!(
1393            result,
1394            oxiui_core::events::Key::Named("Unidentified".to_owned())
1395        );
1396    }
1397
1398    #[test]
1399    fn map_modifiers_ctrl_shift() {
1400        use iced::keyboard::Modifiers;
1401        let mods = Modifiers::CTRL | Modifiers::SHIFT;
1402        let result = map_iced_modifiers(mods);
1403        assert!(result.ctrl, "ctrl must be set");
1404        assert!(result.shift, "shift must be set");
1405        assert!(!result.alt, "alt must not be set");
1406        assert!(!result.meta, "meta must not be set");
1407    }
1408
1409    #[test]
1410    fn map_modifiers_none() {
1411        use iced::keyboard::Modifiers;
1412        let result = map_iced_modifiers(Modifiers::NONE);
1413        assert!(!result.ctrl);
1414        assert!(!result.shift);
1415        assert!(!result.alt);
1416        assert!(!result.meta);
1417    }
1418
1419    #[test]
1420    fn map_modifiers_alt_logo() {
1421        use iced::keyboard::Modifiers;
1422        let mods = Modifiers::ALT | Modifiers::LOGO;
1423        let result = map_iced_modifiers(mods);
1424        assert!(result.alt, "alt must be set");
1425        assert!(result.meta, "meta must be set");
1426        assert!(!result.ctrl);
1427        assert!(!result.shift);
1428    }
1429}