repose_ui/
lib.rs

1#![allow(non_snake_case)]
2//! # Views, Modifiers, and Layout
3//!
4//! Repose UI is built around three core ideas:
5//!
6//! - `View`: an immutable description of a UI node.
7//! - `Modifier`: layout, styling, and interaction hints attached to a `View`.
8//! - Layout + paint: a separate pass (`layout_and_paint`) that turns the
9//!   `View` tree into a `Scene` + hit regions using the Taffy layout engine.
10//!
11//! ## Views
12//!
13//! A `View` is a lightweight value that describes *what* to show, not *how* it is
14//! rendered. It is cheap to create and you are expected to rebuild the entire
15//! view tree on each frame:
16//!
17//! ```rust
18//! use repose_core::*;
19//! use repose_ui::*;
20//!
21//! fn Counter(count: i32, on_inc: impl Fn() + 'static) -> View {
22//!     Column(Modifier::new().padding(16.0)).child((
23//!         Text(format!("Count = {count}")),
24//!         Button("Increment".into_children(), on_inc),
25//!     ))
26//! }
27//! ```
28//!
29//! Internally, a `View` has:
30//!
31//! - `id: ViewId` — assigned during composition/layout.
32//! - `kind: ViewKind` — which widget it is (Text, Button, ScrollV, etc.).
33//! - `modifier: Modifier` — layout/styling/interaction metadata.
34//! - `children: Vec<View>` — owned child views.
35//!
36//! Views are *pure data*: they do not hold state or references into platform
37//! APIs. State lives in signals / `remember_*` and platform integration happens
38//! in the runner (`repose-platform`).
39//!
40//! ## Modifiers
41//!
42//! `Modifier` describes *how* a view participates in layout and hit‑testing:
43//!
44//! - Size hints: `size`, `width`, `height`, `min_size`, `max_size`,
45//!   `fill_max_size`, `fill_max_width`, `fill_max_height`.
46//! - Box model: `padding`, `padding_values`.
47//! - Visuals: `background`, `background_brush`, `border`, `clip_rounded`, `alpha`, `transform`.
48//! - Flex / grid: `flex_grow`, `flex_shrink`, `flex_basis`, `align_self`,
49//!   `justify_content`, `align_items`, `grid`, `grid_span`.
50//! - Positioning: `absolute()`, `offset(..)` for overlay / Stack / FABs.
51//! - Interaction: `clickable()`, pointer callbacks, `on_scroll`, `semantics`.
52//! - Custom paint: `painter` (used by `repose-canvas`).
53//!
54//! Example:
55//!
56//! ```rust
57//! use repose_core::*;
58//! use repose_ui::*;
59//!
60//! fn CardExample() -> View {
61//!     Surface(
62//!         Modifier::new()
63//!             .padding(16.0)
64//!             .background(Color::from_hex("#1E1E1E"))
65//!             .border(1.0, Color::from_hex("#333333"), 8.0)
66//!             .clip_rounded(8.0),
67//!         Text("Hello, Repose!"),
68//!     )
69//! }
70//! ```
71//!
72//! Modifiers are merged into a Taffy `Style` inside `layout_and_paint`. Most
73//! values are specified in density‑independent pixels (dp) and converted to
74//! physical pixels (`px`) using the current `Density` local.
75//!
76//! ## Layout
77//!
78//! Layout is a pure function:
79//!
80//! ```rust
81//! pub fn layout_and_paint(
82//!     root: &View,
83//!     size_px: (u32, u32),
84//!     textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
85//!     interactions: &Interactions,
86//!     focused: Option<u64>,
87//! ) -> (Scene, Vec<HitRegion>, Vec<SemNode>);
88//! ```
89//!
90//! It:
91//!
92//! 1. Clones the root `View` and assigns stable `ViewId`s.
93//! 2. Builds a parallel Taffy tree and computes layout for the given window size.
94//! 3. Walks the tree to:
95//!    - Emit `SceneNode`s for visuals (rects, text, images, scrollbars, etc.).
96//!    - Build `HitRegion`s for input routing (clicks, pointer events, scroll).
97//!    - Build `SemNode`s for accessibility / semantics.
98//!
99//! `Row`, `Column`, `Stack`, `Grid`, `ScrollV` and `ScrollXY` are all special
100//! `ViewKind`s that map into Taffy styles and additional paint/hit logic.
101//!
102//! Because layout + paint are separate from the platform runner, you can reuse
103//! the same UI code on desktop, Android, and other platforms.
104
105pub mod anim;
106pub mod anim_ext;
107pub mod gestures;
108pub mod lazy;
109pub mod navigation;
110pub mod scroll;
111
112use std::collections::{HashMap, HashSet};
113use std::rc::Rc;
114use std::{cell::RefCell, cmp::Ordering};
115
116use repose_core::*;
117use taffy::style::FlexDirection;
118use taffy::{Overflow, Point};
119
120pub mod textfield;
121pub use textfield::{TextField, TextFieldState};
122
123use crate::textfield::{TF_FONT_DP, TF_PADDING_X_DP, byte_to_char_index, measure_text};
124use repose_core::locals;
125
126#[derive(Default)]
127pub struct Interactions {
128    pub hover: Option<u64>,
129    pub pressed: HashSet<u64>,
130}
131
132pub fn Surface(modifier: Modifier, child: View) -> View {
133    let mut v = View::new(0, ViewKind::Surface).modifier(modifier);
134    v.children = vec![child];
135    v
136}
137
138pub fn Box(modifier: Modifier) -> View {
139    View::new(0, ViewKind::Box).modifier(modifier)
140}
141
142pub fn Row(modifier: Modifier) -> View {
143    View::new(0, ViewKind::Row).modifier(modifier)
144}
145
146pub fn Column(modifier: Modifier) -> View {
147    View::new(0, ViewKind::Column).modifier(modifier)
148}
149
150pub fn Stack(modifier: Modifier) -> View {
151    View::new(0, ViewKind::Stack).modifier(modifier)
152}
153
154#[deprecated = "Use ScollArea instead"]
155pub fn Scroll(modifier: Modifier) -> View {
156    View::new(
157        0,
158        ViewKind::ScrollV {
159            on_scroll: None,
160            set_viewport_height: None,
161            set_content_height: None,
162            get_scroll_offset: None,
163            set_scroll_offset: None,
164        },
165    )
166    .modifier(modifier)
167}
168
169pub fn Text(text: impl Into<String>) -> View {
170    View::new(
171        0,
172        ViewKind::Text {
173            text: text.into(),
174            color: Color::WHITE,
175            font_size: 16.0, // dp (converted to px in layout/paint)
176            soft_wrap: true,
177            max_lines: None,
178            overflow: TextOverflow::Visible,
179        },
180    )
181}
182
183pub fn Spacer() -> View {
184    Box(Modifier::new().flex_grow(1.0))
185}
186
187pub fn Grid(
188    columns: usize,
189    modifier: Modifier,
190    children: Vec<View>,
191    row_gap: f32,
192    column_gap: f32,
193) -> View {
194    Column(modifier.grid(columns, row_gap, column_gap)).with_children(children)
195}
196
197pub fn Button(content: impl IntoChildren, on_click: impl Fn() + 'static) -> View {
198    View::new(
199        0,
200        ViewKind::Button {
201            on_click: Some(Rc::new(on_click)),
202        },
203    )
204    .with_children(content.into_children())
205    .semantics(Semantics {
206        role: Role::Button,
207        label: None, // optional: we could derive from first Text child later
208        focused: false,
209        enabled: true,
210    })
211}
212
213pub fn Checkbox(checked: bool, on_change: impl Fn(bool) + 'static) -> View {
214    View::new(
215        0,
216        ViewKind::Checkbox {
217            checked,
218            on_change: Some(Rc::new(on_change)),
219        },
220    )
221    .semantics(Semantics {
222        role: Role::Checkbox,
223        label: None,
224        focused: false,
225        enabled: true,
226    })
227}
228
229pub fn RadioButton(selected: bool, on_select: impl Fn() + 'static) -> View {
230    View::new(
231        0,
232        ViewKind::RadioButton {
233            selected,
234            on_select: Some(Rc::new(on_select)),
235        },
236    )
237    .semantics(Semantics {
238        role: Role::RadioButton,
239        label: None,
240        focused: false,
241        enabled: true,
242    })
243}
244
245pub fn Switch(checked: bool, on_change: impl Fn(bool) + 'static) -> View {
246    View::new(
247        0,
248        ViewKind::Switch {
249            checked,
250            on_change: Some(Rc::new(on_change)),
251        },
252    )
253    .semantics(Semantics {
254        role: Role::Switch,
255        label: None,
256        focused: false,
257        enabled: true,
258    })
259}
260pub fn Slider(
261    value: f32,
262    range: (f32, f32),
263    step: Option<f32>,
264    on_change: impl Fn(f32) + 'static,
265) -> View {
266    View::new(
267        0,
268        ViewKind::Slider {
269            value,
270            min: range.0,
271            max: range.1,
272            step,
273            on_change: Some(Rc::new(on_change)),
274        },
275    )
276    .semantics(Semantics {
277        role: Role::Slider,
278        label: None,
279        focused: false,
280        enabled: true,
281    })
282}
283
284pub fn RangeSlider(
285    start: f32,
286    end: f32,
287    range: (f32, f32),
288    step: Option<f32>,
289    on_change: impl Fn(f32, f32) + 'static,
290) -> View {
291    View::new(
292        0,
293        ViewKind::RangeSlider {
294            start,
295            end,
296            min: range.0,
297            max: range.1,
298            step,
299            on_change: Some(Rc::new(on_change)),
300        },
301    )
302    .semantics(Semantics {
303        role: Role::Slider,
304        label: None,
305        focused: false,
306        enabled: true,
307    })
308}
309
310pub fn LinearProgress(value: Option<f32>) -> View {
311    View::new(
312        0,
313        ViewKind::ProgressBar {
314            value: value.unwrap_or(0.0),
315            min: 0.0,
316            max: 1.0,
317            circular: false,
318        },
319    )
320    .semantics(Semantics {
321        role: Role::ProgressBar,
322        label: None,
323        focused: false,
324        enabled: true,
325    })
326}
327
328pub fn ProgressBar(value: f32, range: (f32, f32)) -> View {
329    View::new(
330        0,
331        ViewKind::ProgressBar {
332            value,
333            min: range.0,
334            max: range.1,
335            circular: false,
336        },
337    )
338    .semantics(Semantics {
339        role: Role::ProgressBar,
340        label: None,
341        focused: false,
342        enabled: true,
343    })
344}
345
346pub fn Image(modifier: Modifier, handle: ImageHandle) -> View {
347    View::new(
348        0,
349        ViewKind::Image {
350            handle,
351            tint: Color::WHITE,
352            fit: ImageFit::Contain,
353        },
354    )
355    .modifier(modifier)
356}
357
358pub trait ImageExt {
359    fn image_tint(self, c: Color) -> View;
360    fn image_fit(self, fit: ImageFit) -> View;
361}
362impl ImageExt for View {
363    fn image_tint(mut self, c: Color) -> View {
364        if let ViewKind::Image { tint, .. } = &mut self.kind {
365            *tint = c;
366        }
367        self
368    }
369    fn image_fit(mut self, fit: ImageFit) -> View {
370        if let ViewKind::Image { fit: f, .. } = &mut self.kind {
371            *f = fit;
372        }
373        self
374    }
375}
376
377fn flex_dir_for(kind: &ViewKind) -> Option<FlexDirection> {
378    match kind {
379        ViewKind::Row => {
380            if repose_core::locals::text_direction() == repose_core::locals::TextDirection::Rtl {
381                Some(FlexDirection::RowReverse)
382            } else {
383                Some(FlexDirection::Row)
384            }
385        }
386        ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. } => {
387            Some(FlexDirection::Column)
388        }
389        _ => None,
390    }
391}
392
393/// Extension trait for child building
394pub trait ViewExt: Sized {
395    fn child(self, children: impl IntoChildren) -> Self;
396}
397
398impl ViewExt for View {
399    fn child(self, children: impl IntoChildren) -> Self {
400        self.with_children(children.into_children())
401    }
402}
403
404pub trait IntoChildren {
405    fn into_children(self) -> Vec<View>;
406}
407
408impl IntoChildren for View {
409    fn into_children(self) -> Vec<View> {
410        vec![self]
411    }
412}
413
414impl IntoChildren for Vec<View> {
415    fn into_children(self) -> Vec<View> {
416        self
417    }
418}
419
420impl<const N: usize> IntoChildren for [View; N] {
421    fn into_children(self) -> Vec<View> {
422        self.into()
423    }
424}
425
426// Tuple implementations
427macro_rules! impl_into_children_tuple {
428    ($($idx:tt $t:ident),+) => {
429        impl<$($t: IntoChildren),+> IntoChildren for ($($t,)+) {
430            fn into_children(self) -> Vec<View> {
431                let mut v = Vec::new();
432                $(v.extend(self.$idx.into_children());)+
433                v
434            }
435        }
436    };
437}
438
439impl_into_children_tuple!(0 A);
440impl_into_children_tuple!(0 A, 1 B);
441impl_into_children_tuple!(0 A, 1 B, 2 C);
442impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D);
443impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E);
444impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F);
445impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G);
446impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H);
447
448/// Layout and paint with TextField state injection (Taffy 0.9 API)
449pub fn layout_and_paint(
450    root: &View,
451    size_px_u32: (u32, u32),
452    textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
453    interactions: &Interactions,
454    focused: Option<u64>,
455) -> (Scene, Vec<HitRegion>, Vec<SemNode>) {
456    // Unit helpers
457    // dp -> px using current Density
458    let px = |dp_val: f32| dp_to_px(dp_val);
459    // font dp -> px with TextScale applied
460    let font_px = |dp_font: f32| dp_to_px(dp_font) * locals::text_scale().0;
461
462    // Rule:
463    // - If `Modifier.key` is set, use it as the per-node identity salt.
464    // - Otherwise use the child index (stable for static trees; dynamic trees should supply keys).
465    // - IDs are derived from parent id + salt via a simple 64-bit mixer.
466    //
467    // This keeps code short (no explicit ID allocation during build) while enabling stability
468    // for dynamic lists via `.key(...)`.
469    fn splitmix64(mut x: u64) -> u64 {
470        x = x.wrapping_add(0x9E3779B97F4A7C15);
471        let mut z = x;
472        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
473        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
474        z ^ (z >> 31)
475    }
476
477    fn hash2(a: u64, b: u64) -> u64 {
478        splitmix64(a ^ splitmix64(b))
479    }
480
481    fn assign_ids(mut v: View, parent: u64, index: u64) -> View {
482        // Prefer explicit keys; otherwise fall back to structural position.
483        let salt = v.modifier.key.unwrap_or(index);
484
485        // Include a domain separator so root/child hashing stays well-distributed.
486        let raw = hash2(parent ^ 0xD6E8FEB86659FD93, salt ^ 0xA5A5_A5A5_A5A5_A5A5);
487
488        // Avoid 0 as a sentinel.
489        v.id = if raw == 0 { 1 } else { raw };
490
491        v.children = v
492            .children
493            .into_iter()
494            .enumerate()
495            .map(|(i, c)| assign_ids(c, v.id, i as u64))
496            .collect();
497
498        v
499    }
500
501    let root = assign_ids(root.clone(), 0, 0);
502
503    // Build Taffy tree (with per-node contexts for measurement)
504    use taffy::prelude::*;
505    #[derive(Clone)]
506    enum NodeCtx {
507        Text {
508            text: String,
509            font_dp: f32, // logical size (dp)
510            soft_wrap: bool,
511            max_lines: Option<usize>,
512            overflow: TextOverflow,
513        },
514        Button {
515            label: String,
516        },
517        TextField,
518        Container,
519        ScrollContainer,
520        Checkbox,
521        Radio,
522        Switch,
523        Slider,
524        Range,
525        Progress,
526    }
527
528    let mut taffy: TaffyTree<NodeCtx> = TaffyTree::new();
529    let mut nodes_map = HashMap::new();
530
531    #[derive(Clone)]
532    struct TextLayout {
533        lines: Vec<String>,
534        size_px: f32,
535        line_h_px: f32,
536    }
537    use std::collections::HashMap as StdHashMap;
538    let mut text_cache: StdHashMap<taffy::NodeId, TextLayout> = StdHashMap::new();
539
540    fn style_from_modifier(m: &Modifier, kind: &ViewKind, px: &dyn Fn(f32) -> f32) -> taffy::Style {
541        use taffy::prelude::*;
542
543        let mut s = Style::default();
544
545        // Display role
546        s.display = match kind {
547            ViewKind::Row => Display::Flex,
548            ViewKind::Column
549            | ViewKind::Surface
550            | ViewKind::ScrollV { .. }
551            | ViewKind::ScrollXY { .. } => Display::Flex,
552            ViewKind::Stack => Display::Grid, // you model Stack as a grid overlay
553            _ => Display::Flex,
554        };
555
556        // Flex direction
557        if matches!(kind, ViewKind::Row) {
558            s.flex_direction =
559                if crate::locals::text_direction() == crate::locals::TextDirection::Rtl {
560                    FlexDirection::RowReverse
561                } else {
562                    FlexDirection::Row
563                };
564        }
565        if matches!(
566            kind,
567            ViewKind::Column
568                | ViewKind::Surface
569                | ViewKind::ScrollV { .. }
570                | ViewKind::ScrollXY { .. }
571        ) {
572            s.flex_direction = FlexDirection::Column;
573        }
574
575        // Default alignment for container-y nodes
576        s.align_items = if matches!(
577            kind,
578            ViewKind::Row
579                | ViewKind::Column
580                | ViewKind::Stack
581                | ViewKind::Surface
582                | ViewKind::ScrollV { .. }
583                | ViewKind::ScrollXY { .. }
584        ) {
585            Some(AlignItems::Stretch)
586        } else {
587            Some(AlignItems::FlexStart)
588        };
589        s.justify_content = Some(JustifyContent::FlexStart);
590
591        // Aspect ratio
592        if let Some(r) = m.aspect_ratio {
593            s.aspect_ratio = Some(r.max(0.0));
594        }
595
596        if matches!(
597            kind,
598            ViewKind::Box
599                | ViewKind::Text { .. }
600                | ViewKind::Button { .. }
601                | ViewKind::TextField { .. }
602                | ViewKind::Image { .. }
603                | ViewKind::Checkbox { .. }
604                | ViewKind::RadioButton { .. }
605                | ViewKind::Switch { .. }
606                | ViewKind::Slider { .. }
607                | ViewKind::RangeSlider { .. }
608                | ViewKind::ProgressBar { .. }
609                | ViewKind::ScrollV { .. }
610                | ViewKind::ScrollXY { .. }
611        ) {
612            s.flex_shrink = 1.0;
613        }
614
615        // Flex props
616        if let Some(g) = m.flex_grow {
617            s.flex_grow = g.max(0.0);
618        }
619        if let Some(sh) = m.flex_shrink {
620            s.flex_shrink = sh.max(0.0);
621        }
622        if let Some(b_dp) = m.flex_basis {
623            s.flex_basis = length(px(b_dp.max(0.0)));
624        }
625        if let Some(w) = m.flex_wrap {
626            s.flex_wrap = w;
627        }
628        if let Some(d) = m.flex_dir {
629            s.flex_direction = d;
630        }
631
632        if let Some(a) = m.align_self {
633            s.align_self = Some(a);
634        }
635        if let Some(j) = m.justify_content {
636            s.justify_content = Some(j);
637        }
638        if let Some(ai) = m.align_items_container {
639            s.align_items = Some(ai);
640        }
641        if let Some(ac) = m.align_content {
642            s.align_content = Some(ac);
643        }
644
645        if let Some(v) = m.margin_top {
646            s.margin.top = length(px(v));
647        }
648        if let Some(v) = m.margin_left {
649            s.margin.left = length(px(v));
650        }
651        if let Some(v) = m.margin_right {
652            s.margin.right = length(px(v));
653        }
654        if let Some(v) = m.margin_bottom {
655            s.margin.bottom = length(px(v));
656        }
657
658        // Absolute positioning
659        if let Some(PositionType::Absolute) = m.position_type {
660            s.position = Position::Absolute;
661            s.inset = taffy::geometry::Rect {
662                left: m.offset_left.map(|v| length(px(v))).unwrap_or_else(auto),
663                right: m.offset_right.map(|v| length(px(v))).unwrap_or_else(auto),
664                top: m.offset_top.map(|v| length(px(v))).unwrap_or_else(auto),
665                bottom: m.offset_bottom.map(|v| length(px(v))).unwrap_or_else(auto),
666            };
667        }
668
669        // Grid config
670        if let Some(cfg) = &m.grid {
671            s.display = Display::Grid;
672            s.grid_template_columns = (0..cfg.columns.max(1))
673                .map(|_| GridTemplateComponent::Single(flex(1.0)))
674                .collect();
675            s.gap = Size {
676                width: length(px(cfg.column_gap)),
677                height: length(px(cfg.row_gap)),
678            };
679        }
680
681        // Scroll containers clip their children; actual clip is drawn in paint pass too
682        if matches!(kind, ViewKind::ScrollV { .. } | ViewKind::ScrollXY { .. }) {
683            s.overflow = Point {
684                x: Overflow::Hidden,
685                y: Overflow::Hidden,
686            };
687        }
688
689        // Padding
690        if let Some(pv_dp) = m.padding_values {
691            s.padding = Rect {
692                left: length(px(pv_dp.left)),
693                right: length(px(pv_dp.right)),
694                top: length(px(pv_dp.top)),
695                bottom: length(px(pv_dp.bottom)),
696            };
697        } else if let Some(p_dp) = m.padding {
698            let v = length(px(p_dp));
699            s.padding = Rect {
700                left: v,
701                right: v,
702                top: v,
703                bottom: v,
704            };
705        }
706
707        // Explicit size (highest priority)
708        let mut width_set = false;
709        let mut height_set = false;
710
711        if let Some(sz_dp) = m.size {
712            if sz_dp.width.is_finite() {
713                s.size.width = length(px(sz_dp.width.max(0.0)));
714                width_set = true;
715            }
716            if sz_dp.height.is_finite() {
717                s.size.height = length(px(sz_dp.height.max(0.0)));
718                height_set = true;
719            }
720        }
721        if let Some(w_dp) = m.width {
722            s.size.width = length(px(w_dp.max(0.0)));
723            width_set = true;
724        }
725        if let Some(h_dp) = m.height {
726            s.size.height = length(px(h_dp.max(0.0)));
727            height_set = true;
728        }
729
730        // Fill semantics (kind-independent)
731        //
732        // This is the only consistent definition you can implement without knowing the parent axis:
733        // - fill_max_width  => width: 100%
734        // - fill_max_height => height: 100%
735        //
736        // For proportional splits in a Row/Column main axis, use `.weight()` / flex_grow.
737        let want_fill_w = m.fill_max || m.fill_max_w;
738        let want_fill_h = m.fill_max || m.fill_max_h;
739
740        if want_fill_w && !width_set {
741            s.size.width = percent(1.0);
742            // allow shrinking inside flex containers (CSS min-width:0)
743            if s.min_size.width.is_auto() {
744                s.min_size.width = length(0.0);
745            }
746        }
747        if want_fill_h && !height_set {
748            s.size.height = percent(1.0);
749            // Avoid making every text/button shrink vertically and paint outside.
750            if matches!(kind, ViewKind::ScrollV { .. } | ViewKind::ScrollXY { .. })
751                && s.min_size.height.is_auto()
752            {
753                s.min_size.height = length(0.0);
754            }
755        }
756
757        // Default min-width fix for flex overflow (CSS min-width:0)
758        if s.min_size.width.is_auto() {
759            s.min_size.width = length(0.0);
760        }
761
762        // Button-specific defaults
763        if matches!(kind, ViewKind::Button { .. }) {
764            s.display = Display::Flex;
765            s.flex_direction =
766                if crate::locals::text_direction() == crate::locals::TextDirection::Rtl {
767                    FlexDirection::RowReverse
768                } else {
769                    FlexDirection::Row
770                };
771
772            if m.justify_content.is_none() {
773                s.justify_content = Some(JustifyContent::Center);
774            }
775            if m.align_items_container.is_none() {
776                s.align_items = Some(AlignItems::Center);
777            }
778
779            // Default padding unless user provided padding
780            if m.padding.is_none() && m.padding_values.is_none() {
781                let ph = px(14.0);
782                let pv = px(10.0);
783                s.padding = Rect {
784                    left: length(ph),
785                    right: length(ph),
786                    top: length(pv),
787                    bottom: length(pv),
788                };
789            }
790
791            // Default min height unless user provided one
792            if m.min_height.is_none() && s.min_size.height.is_auto() {
793                s.min_size.height = length(px(40.0));
794            }
795        }
796
797        // User min/max clamps (treat as dp)
798        if let Some(v_dp) = m.min_width {
799            s.min_size.width = length(px(v_dp.max(0.0)));
800        }
801        if let Some(v_dp) = m.min_height {
802            s.min_size.height = length(px(v_dp.max(0.0)));
803        }
804        if let Some(v_dp) = m.max_width {
805            s.max_size.width = length(px(v_dp.max(0.0)));
806        }
807        if let Some(v_dp) = m.max_height {
808            s.max_size.height = length(px(v_dp.max(0.0)));
809        }
810
811        // Text nodes: never force a min-content width
812        if matches!(kind, ViewKind::Text { .. }) && s.min_size.width.is_auto() {
813            s.min_size.width = length(0.0);
814        }
815        // if s.min_size.height.is_auto() {
816        //     s.min_size.height = length(0.0);
817        // } // Causes aggressive contraints in nested containers, should probably use a screen scroll like jetpack compose
818
819        s
820    }
821
822    fn build_node(
823        v: &View,
824        t: &mut TaffyTree<NodeCtx>,
825        nodes_map: &mut HashMap<ViewId, taffy::NodeId>,
826    ) -> taffy::NodeId {
827        // We'll inject px() at call-site (need locals access); this function
828        // is called from a scope that has the helper closure.
829        let px_helper = |dp_val: f32| dp_to_px(dp_val);
830
831        let mut style = style_from_modifier(&v.modifier, &v.kind, &px_helper);
832
833        if v.modifier.grid_col_span.is_some() || v.modifier.grid_row_span.is_some() {
834            use taffy::prelude::{GridPlacement, Line};
835
836            let col_span = v.modifier.grid_col_span.unwrap_or(1).max(1);
837            let row_span = v.modifier.grid_row_span.unwrap_or(1).max(1);
838
839            style.grid_column = Line {
840                start: GridPlacement::Auto,
841                end: GridPlacement::Span(col_span),
842            };
843            style.grid_row = Line {
844                start: GridPlacement::Auto,
845                end: GridPlacement::Span(row_span),
846            };
847        }
848
849        let children: Vec<_> = v
850            .children
851            .iter()
852            .map(|c| build_node(c, t, nodes_map))
853            .collect();
854
855        let node = match &v.kind {
856            ViewKind::Text {
857                text,
858                font_size: font_dp,
859                soft_wrap,
860                max_lines,
861                overflow,
862                ..
863            } => t
864                .new_leaf_with_context(
865                    style,
866                    NodeCtx::Text {
867                        text: text.clone(),
868                        font_dp: *font_dp,
869                        soft_wrap: *soft_wrap,
870                        max_lines: *max_lines,
871                        overflow: *overflow,
872                    },
873                )
874                .unwrap(),
875            ViewKind::Button { .. } => {
876                let children: Vec<_> = v
877                    .children
878                    .iter()
879                    .map(|c| build_node(c, t, nodes_map))
880                    .collect();
881                let n = t.new_with_children(style, &children).unwrap();
882                t.set_node_context(n, Some(NodeCtx::Container)).ok();
883                n
884            }
885            ViewKind::TextField { .. } => {
886                t.new_leaf_with_context(style, NodeCtx::TextField).unwrap()
887            }
888            ViewKind::Image { .. } => t.new_leaf_with_context(style, NodeCtx::Container).unwrap(),
889            ViewKind::Checkbox { .. } => t
890                .new_leaf_with_context(style, NodeCtx::Checkbox {})
891                .unwrap(),
892            ViewKind::RadioButton { .. } => {
893                t.new_leaf_with_context(style, NodeCtx::Radio {}).unwrap()
894            }
895            ViewKind::Switch { .. } => t.new_leaf_with_context(style, NodeCtx::Switch {}).unwrap(),
896            ViewKind::Slider { .. } => t.new_leaf_with_context(style, NodeCtx::Slider).unwrap(),
897            ViewKind::RangeSlider { .. } => t.new_leaf_with_context(style, NodeCtx::Range).unwrap(),
898            ViewKind::ProgressBar { .. } => {
899                t.new_leaf_with_context(style, NodeCtx::Progress).unwrap()
900            }
901            ViewKind::ScrollV { .. } => {
902                let children: Vec<_> = v
903                    .children
904                    .iter()
905                    .map(|c| build_node(c, t, nodes_map))
906                    .collect();
907
908                let n = t.new_with_children(style, &children).unwrap();
909                t.set_node_context(n, Some(NodeCtx::ScrollContainer)).ok();
910                n
911            }
912            _ => {
913                let n = t.new_with_children(style, &children).unwrap();
914                t.set_node_context(n, Some(NodeCtx::Container)).ok();
915                n
916            }
917        };
918
919        nodes_map.insert(v.id, node);
920        node
921    }
922
923    let root_node = build_node(&root, &mut taffy, &mut nodes_map);
924
925    {
926        let mut rs = taffy.style(root_node).unwrap().clone();
927        rs.size.width = length(size_px_u32.0 as f32);
928        rs.size.height = length(size_px_u32.1 as f32);
929        taffy.set_style(root_node, rs).unwrap();
930    }
931
932    let available = taffy::geometry::Size {
933        width: AvailableSpace::Definite(size_px_u32.0 as f32),
934        height: AvailableSpace::Definite(size_px_u32.1 as f32),
935    };
936
937    // Measure function for intrinsic content
938    taffy
939        .compute_layout_with_measure(root_node, available, |known, avail, node, ctx, _style| {
940            match ctx {
941                Some(NodeCtx::Text {
942                    text,
943                    font_dp,
944                    soft_wrap,
945                    max_lines,
946                    overflow,
947                }) => {
948                    let size_px_val = font_px(*font_dp);
949                    let line_h_px_val = size_px_val * 1.3;
950
951                    // If available width is Definite, we must respect it (wrap).
952                    // If Indefinite (e.g. inside a center-aligned Button), we allow INFINITY
953                    // to prevent premature wrapping of short words like "Home".
954                    let target_w_px = match avail.width {
955                        AvailableSpace::Definite(w) if w > 0.5 => w,
956                        _ => f32::INFINITY,
957                    };
958
959                    let mut lines_vec: Vec<String>;
960
961                    if *soft_wrap {
962                        // Pass target_w_px. If it's INFINITY, wrap_lines won't wrap.
963                        let (ls, trunc) = repose_text::wrap_lines(
964                            text,
965                            size_px_val,
966                            target_w_px,
967                            *max_lines,
968                            true,
969                        );
970                        lines_vec = ls;
971                        if matches!(overflow, TextOverflow::Ellipsis)
972                            && trunc
973                            && !lines_vec.is_empty()
974                        {
975                            let last = lines_vec.len() - 1;
976                            // Ensure we don't pass INFINITY to ellipsize_line if we somehow truncated
977                            let limit = if target_w_px.is_finite() {
978                                target_w_px
979                            } else {
980                                f32::MAX
981                            };
982                            lines_vec[last] =
983                                repose_text::ellipsize_line(&lines_vec[last], size_px_val, limit);
984                        }
985                    } else if matches!(overflow, TextOverflow::Ellipsis) {
986                        // Check exact width before ellipsizing
987                        let m = measure_text(text, size_px_val);
988                        let true_w = m.positions.last().copied().unwrap_or(0.0);
989
990                        if target_w_px.is_finite() && true_w > target_w_px + 0.5 {
991                            lines_vec =
992                                vec![repose_text::ellipsize_line(text, size_px_val, target_w_px)];
993                        } else {
994                            lines_vec = vec![text.clone()];
995                        }
996                    } else {
997                        lines_vec = vec![text.clone()];
998                    }
999
1000                    // Cache for paint (much better perf.)
1001                    text_cache.insert(
1002                        node,
1003                        TextLayout {
1004                            lines: lines_vec.clone(),
1005                            size_px: size_px_val,
1006                            line_h_px: line_h_px_val,
1007                        },
1008                    );
1009
1010                    // If `target_w_px` is returned (which might be the full Button width), the text node becomes huge, and since text is drawn at x=0, it looks left-aligned.
1011                    let max_line_w = lines_vec
1012                        .iter()
1013                        .map(|line| {
1014                            let m = measure_text(line, size_px_val);
1015                            m.positions.last().copied().unwrap_or(0.0)
1016                        })
1017                        .fold(0.0f32, f32::max);
1018
1019                    taffy::geometry::Size {
1020                        width: known.width.unwrap_or(max_line_w),
1021                        height: line_h_px_val * lines_vec.len().max(1) as f32,
1022                    }
1023                }
1024                Some(NodeCtx::Button { label }) => taffy::geometry::Size {
1025                    width: (label.len() as f32 * font_px(16.0) * 0.6) + px(24.0),
1026                    height: px(36.0),
1027                },
1028                Some(NodeCtx::TextField) => taffy::geometry::Size {
1029                    width: known.width.unwrap_or(px(120.0)),
1030                    height: px(36.0),
1031                },
1032                Some(NodeCtx::Checkbox) => taffy::geometry::Size {
1033                    width: known.width.unwrap_or(px(24.0)),
1034                    height: px(24.0),
1035                },
1036                Some(NodeCtx::Radio) => taffy::geometry::Size {
1037                    width: known.width.unwrap_or(px(18.0)),
1038                    height: px(18.0),
1039                },
1040                Some(NodeCtx::Switch) => taffy::geometry::Size {
1041                    width: known.width.unwrap_or(px(46.0)),
1042                    height: px(28.0),
1043                },
1044                Some(NodeCtx::Slider) => taffy::geometry::Size {
1045                    width: known.width.unwrap_or(px(200.0)),
1046                    height: px(28.0),
1047                },
1048                Some(NodeCtx::Range) => taffy::geometry::Size {
1049                    width: known.width.unwrap_or(px(220.0)),
1050                    height: px(28.0),
1051                },
1052                Some(NodeCtx::Progress) => taffy::geometry::Size {
1053                    width: known.width.unwrap_or(px(200.0)),
1054                    height: px(12.0),
1055                },
1056                Some(NodeCtx::ScrollContainer) | Some(NodeCtx::Container) | None => {
1057                    taffy::geometry::Size::ZERO
1058                }
1059            }
1060        })
1061        .unwrap();
1062
1063    fn layout_of(node: taffy::NodeId, t: &TaffyTree<impl Clone>) -> repose_core::Rect {
1064        let l = t.layout(node).unwrap();
1065        repose_core::Rect {
1066            x: l.location.x,
1067            y: l.location.y,
1068            w: l.size.width,
1069            h: l.size.height,
1070        }
1071    }
1072
1073    fn add_offset(mut r: repose_core::Rect, off: (f32, f32)) -> repose_core::Rect {
1074        r.x += off.0;
1075        r.y += off.1;
1076        r
1077    }
1078
1079    // Rect intersection helper for hit clipping
1080    fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> Option<repose_core::Rect> {
1081        let x0 = a.x.max(b.x);
1082        let y0 = a.y.max(b.y);
1083        let x1 = (a.x + a.w).min(b.x + b.w);
1084        let y1 = (a.y + a.h).min(b.y + b.h);
1085        let w = (x1 - x0).max(0.0);
1086        let h = (y1 - y0).max(0.0);
1087        if w <= 0.0 || h <= 0.0 {
1088            None
1089        } else {
1090            Some(repose_core::Rect { x: x0, y: y0, w, h })
1091        }
1092    }
1093
1094    fn clamp01(x: f32) -> f32 {
1095        x.max(0.0).min(1.0)
1096    }
1097    fn norm(value: f32, min: f32, max: f32) -> f32 {
1098        if max > min {
1099            (value - min) / (max - min)
1100        } else {
1101            0.0
1102        }
1103    }
1104    fn denorm(t: f32, min: f32, max: f32) -> f32 {
1105        min + t * (max - min)
1106    }
1107    fn snap_step(v: f32, step: Option<f32>, min: f32, max: f32) -> f32 {
1108        match step {
1109            Some(s) if s > 0.0 => {
1110                let k = ((v - min) / s).round();
1111                (min + k * s).clamp(min, max)
1112            }
1113            _ => v.clamp(min, max),
1114        }
1115    }
1116
1117    /// Multiply alpha into a Color
1118    fn mul_alpha_color(c: Color, a: f32) -> Color {
1119        Color(c.0, c.1, c.2, ((c.3 as f32) * a).clamp(0.0, 255.0) as u8)
1120    }
1121
1122    /// Multiply alpha into a Brush (applies to all colors in the brush)
1123    fn mul_alpha_brush(b: Brush, a: f32) -> Brush {
1124        match b {
1125            Brush::Solid(c) => Brush::Solid(mul_alpha_color(c, a)),
1126            Brush::Linear {
1127                start,
1128                end,
1129                start_color,
1130                end_color,
1131            } => Brush::Linear {
1132                start,
1133                end,
1134                start_color: mul_alpha_color(start_color, a),
1135                end_color: mul_alpha_color(end_color, a),
1136            },
1137        }
1138    }
1139
1140    // draws scrollbar and registers their drag hit regions (both)
1141    fn push_scrollbar_v(
1142        scene: &mut Scene,
1143        hits: &mut Vec<HitRegion>,
1144        interactions: &Interactions,
1145        view_id: u64,
1146        vp: crate::Rect,
1147        content_h_px: f32,
1148        off_y_px: f32,
1149        z: f32,
1150        set_scroll_offset: Option<Rc<dyn Fn(f32)>>,
1151    ) {
1152        if content_h_px <= vp.h + 0.5 {
1153            return;
1154        }
1155        let thickness_px = dp_to_px(6.0);
1156        let margin_px = dp_to_px(2.0);
1157        let min_thumb_px = dp_to_px(24.0);
1158        let th = locals::theme();
1159
1160        // Track geometry (inset inside viewport)
1161        // Track geometry (inset inside viewport)
1162        let track_x = vp.x + vp.w - margin_px - thickness_px;
1163        let track_y = vp.y + margin_px;
1164        let track_h = (vp.h - 2.0 * margin_px).max(0.0);
1165
1166        // If the track is too small, don't draw / interact
1167        if track_h <= 0.5 {
1168            return;
1169        }
1170
1171        // Thumb geometry from content ratio
1172        let ratio = (vp.h / content_h_px).clamp(0.0, 1.0);
1173
1174        let thumb_h = (track_h * ratio)
1175            .max(min_thumb_px.min(track_h))
1176            .min(track_h);
1177
1178        if thumb_h <= 0.5 {
1179            return;
1180        }
1181
1182        let denom = (content_h_px - vp.h).max(1.0);
1183        let tpos = (off_y_px / denom).clamp(0.0, 1.0);
1184        let max_pos = (track_h - thumb_h).max(0.0);
1185        let thumb_y = track_y + tpos * max_pos;
1186
1187        scene.nodes.push(SceneNode::Rect {
1188            rect: crate::Rect {
1189                x: track_x,
1190                y: track_y,
1191                w: thickness_px,
1192                h: track_h,
1193            },
1194            brush: Brush::Solid(th.scrollbar_track),
1195            radius: thickness_px * 0.5,
1196        });
1197        scene.nodes.push(SceneNode::Rect {
1198            rect: crate::Rect {
1199                x: track_x,
1200                y: thumb_y,
1201                w: thickness_px,
1202                h: thumb_h,
1203            },
1204            brush: Brush::Solid(th.scrollbar_thumb),
1205            radius: thickness_px * 0.5,
1206        });
1207        if let Some(setter) = set_scroll_offset {
1208            let thumb_id: u64 = view_id ^ 0x8000_0001;
1209            let map_to_off = Rc::new(move |py_px: f32| -> f32 {
1210                let denom = (content_h_px - vp.h).max(1.0);
1211                let max_pos = (track_h - thumb_h).max(0.0);
1212                let pos = ((py_px - track_y) - thumb_h * 0.5).clamp(0.0, max_pos);
1213                let t = if max_pos > 0.0 { pos / max_pos } else { 0.0 };
1214                t * denom
1215            });
1216            let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1217                let setter = setter.clone();
1218                let map = map_to_off.clone();
1219                Rc::new(move |pe| setter(map(pe.position.y)))
1220            };
1221            let on_pm: Option<Rc<dyn Fn(repose_core::input::PointerEvent)>> =
1222                if interactions.pressed.contains(&thumb_id) {
1223                    let setter = setter.clone();
1224                    let map = map_to_off.clone();
1225                    Some(Rc::new(move |pe| setter(map(pe.position.y))))
1226                } else {
1227                    None
1228                };
1229            let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
1230            hits.push(HitRegion {
1231                id: thumb_id,
1232                rect: crate::Rect {
1233                    x: track_x,
1234                    y: thumb_y,
1235                    w: thickness_px,
1236                    h: thumb_h,
1237                },
1238                on_click: None,
1239                on_scroll: None,
1240                focusable: false,
1241                on_pointer_down: Some(on_pd),
1242                on_pointer_move: on_pm,
1243                on_pointer_up: Some(on_pu),
1244                on_pointer_enter: None,
1245                on_pointer_leave: None,
1246                z_index: z + 1000.0,
1247                on_text_change: None,
1248                on_text_submit: None,
1249                tf_state_key: None,
1250            });
1251        }
1252    }
1253
1254    fn push_scrollbar_h(
1255        scene: &mut Scene,
1256        hits: &mut Vec<HitRegion>,
1257        interactions: &Interactions,
1258        view_id: u64,
1259        vp: crate::Rect,
1260        content_w_px: f32,
1261        off_x_px: f32,
1262        z: f32,
1263        set_scroll_offset_xy: Option<Rc<dyn Fn(f32, f32)>>,
1264        keep_y: f32,
1265    ) {
1266        if content_w_px <= vp.w + 0.5 {
1267            return;
1268        }
1269        let thickness_px = dp_to_px(6.0);
1270        let margin_px = dp_to_px(2.0);
1271        let min_thumb_px = dp_to_px(24.0);
1272        let th = locals::theme();
1273        let track_x = vp.x + margin_px;
1274        let track_y = vp.y + vp.h - margin_px - thickness_px;
1275        let track_w = (vp.w - 2.0 * margin_px).max(0.0);
1276
1277        if track_w <= 0.5 {
1278            return;
1279        }
1280
1281        let ratio = (vp.w / content_w_px).clamp(0.0, 1.0);
1282
1283        let thumb_w = (track_w * ratio)
1284            .max(min_thumb_px.min(track_w))
1285            .min(track_w);
1286
1287        if thumb_w <= 0.5 {
1288            return;
1289        }
1290
1291        let denom = (content_w_px - vp.w).max(1.0);
1292        let tpos = (off_x_px / denom).clamp(0.0, 1.0);
1293        let max_pos = (track_w - thumb_w).max(0.0);
1294        let thumb_x = track_x + tpos * max_pos;
1295
1296        scene.nodes.push(SceneNode::Rect {
1297            rect: crate::Rect {
1298                x: track_x,
1299                y: track_y,
1300                w: track_w,
1301                h: thickness_px,
1302            },
1303            brush: Brush::Solid(th.scrollbar_track),
1304            radius: thickness_px * 0.5,
1305        });
1306        scene.nodes.push(SceneNode::Rect {
1307            rect: crate::Rect {
1308                x: thumb_x,
1309                y: track_y,
1310                w: thumb_w,
1311                h: thickness_px,
1312            },
1313            brush: Brush::Solid(th.scrollbar_thumb),
1314            radius: thickness_px * 0.5,
1315        });
1316        if let Some(set_xy) = set_scroll_offset_xy {
1317            let hthumb_id: u64 = view_id ^ 0x8000_0012;
1318            let map_to_off_x = Rc::new(move |px_pos: f32| -> f32 {
1319                let denom = (content_w_px - vp.w).max(1.0);
1320                let max_pos = (track_w - thumb_w).max(0.0);
1321                let pos = ((px_pos - track_x) - thumb_w * 0.5).clamp(0.0, max_pos);
1322                let t = if max_pos > 0.0 { pos / max_pos } else { 0.0 };
1323                t * denom
1324            });
1325            let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1326                let set_xy = set_xy.clone();
1327                let map = map_to_off_x.clone();
1328                Rc::new(move |pe| set_xy(map(pe.position.x), keep_y))
1329            };
1330            let on_pm: Option<Rc<dyn Fn(repose_core::input::PointerEvent)>> =
1331                if interactions.pressed.contains(&hthumb_id) {
1332                    let set_xy = set_xy.clone();
1333                    let map = map_to_off_x.clone();
1334                    Some(Rc::new(move |pe| set_xy(map(pe.position.x), keep_y)))
1335                } else {
1336                    None
1337                };
1338            let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
1339            hits.push(HitRegion {
1340                id: hthumb_id,
1341                rect: crate::Rect {
1342                    x: thumb_x,
1343                    y: track_y,
1344                    w: thumb_w,
1345                    h: thickness_px,
1346                },
1347                on_click: None,
1348                on_scroll: None,
1349                focusable: false,
1350                on_pointer_down: Some(on_pd),
1351                on_pointer_move: on_pm,
1352                on_pointer_up: Some(on_pu),
1353                on_pointer_enter: None,
1354                on_pointer_leave: None,
1355                z_index: z + 1000.0,
1356                on_text_change: None,
1357                on_text_submit: None,
1358                tf_state_key: None,
1359            });
1360        }
1361    }
1362
1363    let mut scene = Scene {
1364        clear_color: locals::theme().background,
1365        nodes: vec![],
1366    };
1367    let mut hits: Vec<HitRegion> = vec![];
1368    let mut sems: Vec<SemNode> = vec![];
1369
1370    fn walk(
1371        v: &View,
1372        t: &TaffyTree<NodeCtx>,
1373        nodes: &HashMap<ViewId, taffy::NodeId>,
1374        scene: &mut Scene,
1375        hits: &mut Vec<HitRegion>,
1376        sems: &mut Vec<SemNode>,
1377        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
1378        interactions: &Interactions,
1379        focused: Option<u64>,
1380        parent_offset_px: (f32, f32),
1381        alpha_accum: f32,
1382        text_cache: &StdHashMap<taffy::NodeId, TextLayout>,
1383        font_px: &dyn Fn(f32) -> f32,
1384    ) {
1385        let local = layout_of(nodes[&v.id], t);
1386        let rect = add_offset(local, parent_offset_px);
1387
1388        // Convert padding from dp to px for content rect
1389        let content_rect = {
1390            if let Some(pv_dp) = v.modifier.padding_values {
1391                crate::Rect {
1392                    x: rect.x + dp_to_px(pv_dp.left),
1393                    y: rect.y + dp_to_px(pv_dp.top),
1394                    w: (rect.w - dp_to_px(pv_dp.left) - dp_to_px(pv_dp.right)).max(0.0),
1395                    h: (rect.h - dp_to_px(pv_dp.top) - dp_to_px(pv_dp.bottom)).max(0.0),
1396                }
1397            } else if let Some(p_dp) = v.modifier.padding {
1398                let p_px = dp_to_px(p_dp);
1399                crate::Rect {
1400                    x: rect.x + p_px,
1401                    y: rect.y + p_px,
1402                    w: (rect.w - 2.0 * p_px).max(0.0),
1403                    h: (rect.h - 2.0 * p_px).max(0.0),
1404                }
1405            } else {
1406                rect
1407            }
1408        };
1409
1410        let pad_dx = content_rect.x - rect.x;
1411        let pad_dy = content_rect.y - rect.y;
1412
1413        let base_px = (parent_offset_px.0 + local.x, parent_offset_px.1 + local.y);
1414
1415        let is_hovered = interactions.hover == Some(v.id);
1416        let is_pressed = interactions.pressed.contains(&v.id);
1417        let is_focused = focused == Some(v.id);
1418
1419        // Background
1420        if let Some(bg_brush) = v.modifier.background {
1421            scene.nodes.push(SceneNode::Rect {
1422                rect,
1423                brush: mul_alpha_brush(bg_brush, alpha_accum),
1424                radius: v.modifier.clip_rounded.map(dp_to_px).unwrap_or(0.0),
1425            });
1426        }
1427
1428        // Border
1429        if let Some(b) = &v.modifier.border {
1430            scene.nodes.push(SceneNode::Border {
1431                rect,
1432                color: mul_alpha_color(b.color, alpha_accum),
1433                width: dp_to_px(b.width),
1434                radius: dp_to_px(b.radius.max(v.modifier.clip_rounded.unwrap_or(0.0))),
1435            });
1436        }
1437
1438        // Transform and alpha
1439        let this_alpha = v.modifier.alpha.unwrap_or(1.0);
1440        let alpha_accum = (alpha_accum * this_alpha).clamp(0.0, 1.0);
1441
1442        if let Some(tf) = v.modifier.transform {
1443            scene.nodes.push(SceneNode::PushTransform { transform: tf });
1444        }
1445
1446        // Custom painter (Canvas)
1447        if let Some(p) = &v.modifier.painter {
1448            (p)(scene, rect);
1449        }
1450
1451        let has_pointer = v.modifier.on_pointer_down.is_some()
1452            || v.modifier.on_pointer_move.is_some()
1453            || v.modifier.on_pointer_up.is_some()
1454            || v.modifier.on_pointer_enter.is_some()
1455            || v.modifier.on_pointer_leave.is_some();
1456
1457        if has_pointer || v.modifier.click {
1458            hits.push(HitRegion {
1459                id: v.id,
1460                rect,
1461                on_click: None,  // unless ViewKind provides one
1462                on_scroll: None, // provided by ScrollV case
1463                focusable: false,
1464                on_pointer_down: v.modifier.on_pointer_down.clone(),
1465                on_pointer_move: v.modifier.on_pointer_move.clone(),
1466                on_pointer_up: v.modifier.on_pointer_up.clone(),
1467                on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1468                on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1469                z_index: v.modifier.z_index,
1470                on_text_change: None,
1471                on_text_submit: None,
1472                tf_state_key: None,
1473            });
1474        }
1475
1476        match &v.kind {
1477            ViewKind::Text {
1478                text,
1479                color,
1480                font_size: font_dp,
1481                soft_wrap,
1482                max_lines,
1483                overflow,
1484            } => {
1485                let nid = nodes[&v.id];
1486                let tl = text_cache.get(&nid);
1487
1488                let (size_px_val, line_h_px_val, mut lines): (f32, f32, Vec<String>) =
1489                    if let Some(tl) = tl {
1490                        (tl.size_px, tl.line_h_px, tl.lines.clone())
1491                    } else {
1492                        // Fallback
1493                        let sz_px = font_px(*font_dp);
1494                        (sz_px, sz_px * 1.3, vec![text.clone()])
1495                    };
1496
1497                // Work within the content box
1498                let mut draw_box = content_rect;
1499                let max_w_px = draw_box.w.max(0.0);
1500                let max_h_px = draw_box.h.max(0.0);
1501
1502                // Vertical centering
1503                if lines.len() == 1 && !*soft_wrap {
1504                    let dy_px = (draw_box.h - line_h_px_val) * 0.5;
1505                    if dy_px.is_finite() && dy_px > 0.0 {
1506                        draw_box.y += dy_px;
1507                        draw_box.h = line_h_px_val;
1508                    }
1509                }
1510
1511                // Calculate total text height
1512                let total_text_height = lines.len() as f32 * line_h_px_val;
1513
1514                let need_v_clip =
1515                    total_text_height > max_h_px + 0.5 && *overflow != TextOverflow::Visible;
1516
1517                let max_visual_lines = if max_h_px > 0.5 && need_v_clip {
1518                    (max_h_px / line_h_px_val).floor().max(1.0) as usize
1519                } else {
1520                    lines.len()
1521                };
1522
1523                if lines.len() > max_visual_lines {
1524                    lines.truncate(max_visual_lines);
1525                    if *overflow == TextOverflow::Ellipsis && max_w_px > 0.5 && !lines.is_empty() {
1526                        // Ellipsize the last visible line
1527                        if let Some(last) = lines.last_mut() {
1528                            *last = repose_text::ellipsize_line(last, size_px_val, max_w_px);
1529                        }
1530                    }
1531                }
1532
1533                let approx_w_px = (text.len() as f32) * size_px_val * 0.6;
1534                let need_h_clip = match overflow {
1535                    TextOverflow::Visible => false,
1536                    TextOverflow::Ellipsis => false, // Ellipsis handled above
1537                    TextOverflow::Clip => approx_w_px > max_w_px + 0.5 || need_v_clip,
1538                };
1539
1540                let need_clip = need_h_clip || need_v_clip;
1541
1542                if need_clip {
1543                    scene.nodes.push(SceneNode::PushClip {
1544                        rect: content_rect,
1545                        radius: 0.0,
1546                    });
1547                }
1548
1549                // Horizontal ellipsis for non-wrapped text
1550                if !*soft_wrap
1551                    && matches!(overflow, TextOverflow::Ellipsis)
1552                    && approx_w_px > max_w_px + 0.5
1553                {
1554                    lines = vec![repose_text::ellipsize_line(text, size_px_val, max_w_px)];
1555                }
1556
1557                for (i, ln) in lines.iter().enumerate() {
1558                    scene.nodes.push(SceneNode::Text {
1559                        rect: crate::Rect {
1560                            x: content_rect.x,
1561                            y: content_rect.y + i as f32 * line_h_px_val,
1562                            w: content_rect.w,
1563                            h: line_h_px_val,
1564                        },
1565                        text: ln.clone(),
1566                        color: mul_alpha_color(*color, alpha_accum),
1567                        size: size_px_val,
1568                    });
1569                }
1570
1571                if need_clip {
1572                    scene.nodes.push(SceneNode::PopClip);
1573                }
1574
1575                sems.push(SemNode {
1576                    id: v.id,
1577                    role: Role::Text,
1578                    label: Some(text.clone()),
1579                    rect,
1580                    focused: is_focused,
1581                    enabled: true,
1582                });
1583            }
1584
1585            ViewKind::Button { on_click } => {
1586                // Default background if none provided
1587                if v.modifier.background.is_none() {
1588                    let th = locals::theme();
1589                    let base = if is_pressed {
1590                        th.button_bg_pressed
1591                    } else if is_hovered {
1592                        th.button_bg_hover
1593                    } else {
1594                        th.button_bg
1595                    };
1596                    scene.nodes.push(SceneNode::Rect {
1597                        rect,
1598                        brush: Brush::Solid(mul_alpha_color(base, alpha_accum)),
1599                        radius: v.modifier.clip_rounded.map(dp_to_px).unwrap_or(6.0),
1600                    });
1601                }
1602
1603                if v.modifier.click || on_click.is_some() {
1604                    hits.push(HitRegion {
1605                        id: v.id,
1606                        rect,
1607                        on_click: on_click.clone(),
1608                        on_scroll: None,
1609                        focusable: true,
1610                        on_pointer_down: v.modifier.on_pointer_down.clone(),
1611                        on_pointer_move: v.modifier.on_pointer_move.clone(),
1612                        on_pointer_up: v.modifier.on_pointer_up.clone(),
1613                        on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1614                        on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1615                        z_index: v.modifier.z_index,
1616                        on_text_change: None,
1617                        on_text_submit: None,
1618                        tf_state_key: None,
1619                    });
1620                }
1621
1622                sems.push(SemNode {
1623                    id: v.id,
1624                    role: Role::Button,
1625                    label: None,
1626                    rect,
1627                    focused: is_focused,
1628                    enabled: true,
1629                });
1630
1631                if is_focused {
1632                    scene.nodes.push(SceneNode::Border {
1633                        rect,
1634                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
1635                        width: dp_to_px(2.0),
1636                        radius: v
1637                            .modifier
1638                            .clip_rounded
1639                            .map(dp_to_px)
1640                            .unwrap_or(dp_to_px(6.0)),
1641                    });
1642                }
1643            }
1644            ViewKind::Image { handle, tint, fit } => {
1645                scene.nodes.push(SceneNode::Image {
1646                    rect,
1647                    handle: *handle,
1648                    tint: mul_alpha_color(*tint, alpha_accum),
1649                    fit: *fit,
1650                });
1651            }
1652
1653            ViewKind::TextField {
1654                state_key,
1655                hint,
1656                on_change,
1657                on_submit,
1658                ..
1659            } => {
1660                // Persistent key for platform-managed state
1661                let tf_key = if *state_key != 0 { *state_key } else { v.id };
1662
1663                hits.push(HitRegion {
1664                    id: v.id,
1665                    rect,
1666                    on_click: None,
1667                    on_scroll: None,
1668                    focusable: true,
1669                    on_pointer_down: None,
1670                    on_pointer_move: None,
1671                    on_pointer_up: None,
1672                    on_pointer_enter: None,
1673                    on_pointer_leave: None,
1674                    z_index: v.modifier.z_index,
1675                    on_text_change: on_change.clone(),
1676                    on_text_submit: on_submit.clone(),
1677                    tf_state_key: Some(tf_key),
1678                });
1679
1680                // Inner content rect (padding)
1681                let pad_x_px = dp_to_px(TF_PADDING_X_DP);
1682                let inner = repose_core::Rect {
1683                    x: rect.x + pad_x_px,
1684                    y: rect.y + dp_to_px(8.0),
1685                    w: rect.w - 2.0 * pad_x_px,
1686                    h: rect.h - dp_to_px(16.0),
1687                };
1688                scene.nodes.push(SceneNode::PushClip {
1689                    rect: inner,
1690                    radius: 0.0,
1691                });
1692                // TextField focus ring
1693                if is_focused {
1694                    scene.nodes.push(SceneNode::Border {
1695                        rect,
1696                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
1697                        width: dp_to_px(2.0),
1698                        radius: v
1699                            .modifier
1700                            .clip_rounded
1701                            .map(dp_to_px)
1702                            .unwrap_or(dp_to_px(6.0)),
1703                    });
1704                }
1705
1706                if let Some(state_rc) = textfield_states
1707                    .get(&tf_key)
1708                    .or_else(|| textfield_states.get(&v.id))
1709                // fallback for older platforms
1710                {
1711                    state_rc.borrow_mut().set_inner_width(inner.w);
1712
1713                    let state = state_rc.borrow();
1714                    let text_val = &state.text;
1715                    let font_px_val = font_px(TF_FONT_DP);
1716                    let m = measure_text(text_val, font_px_val);
1717
1718                    // Selection highlight
1719                    if state.selection.start != state.selection.end {
1720                        let i0 = byte_to_char_index(&m, state.selection.start);
1721                        let i1 = byte_to_char_index(&m, state.selection.end);
1722                        let sx_px =
1723                            m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
1724                        let ex_px =
1725                            m.positions.get(i1).copied().unwrap_or(sx_px) - state.scroll_offset;
1726                        let sel_x_px = inner.x + sx_px.max(0.0);
1727                        let sel_w_px = (ex_px - sx_px).max(0.0);
1728                        scene.nodes.push(SceneNode::Rect {
1729                            rect: repose_core::Rect {
1730                                x: sel_x_px,
1731                                y: inner.y,
1732                                w: sel_w_px,
1733                                h: inner.h,
1734                            },
1735                            brush: Brush::Solid(mul_alpha_color(
1736                                Color::from_hex("#3B7BFF55"),
1737                                alpha_accum,
1738                            )),
1739                            radius: 0.0,
1740                        });
1741                    }
1742
1743                    // Composition underline
1744                    if let Some(range) = &state.composition
1745                        && range.start < range.end
1746                        && !text_val.is_empty()
1747                    {
1748                        let i0 = byte_to_char_index(&m, range.start);
1749                        let i1 = byte_to_char_index(&m, range.end);
1750                        let sx_px =
1751                            m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
1752                        let ex_px =
1753                            m.positions.get(i1).copied().unwrap_or(sx_px) - state.scroll_offset;
1754                        let ux = inner.x + sx_px.max(0.0);
1755                        let uw = (ex_px - sx_px).max(0.0);
1756                        scene.nodes.push(SceneNode::Rect {
1757                            rect: repose_core::Rect {
1758                                x: ux,
1759                                y: inner.y + inner.h - dp_to_px(2.0),
1760                                w: uw,
1761                                h: dp_to_px(2.0),
1762                            },
1763                            brush: Brush::Solid(mul_alpha_color(
1764                                locals::theme().focus,
1765                                alpha_accum,
1766                            )),
1767                            radius: 0.0,
1768                        });
1769                    }
1770
1771                    // Text (offset by scroll)
1772                    let text_color = if text_val.is_empty() {
1773                        mul_alpha_color(Color::from_hex("#666666"), alpha_accum)
1774                    } else {
1775                        mul_alpha_color(locals::theme().on_surface, alpha_accum)
1776                    };
1777                    scene.nodes.push(SceneNode::Text {
1778                        rect: repose_core::Rect {
1779                            x: inner.x - state.scroll_offset,
1780                            y: inner.y,
1781                            w: inner.w,
1782                            h: inner.h,
1783                        },
1784                        text: if text_val.is_empty() {
1785                            hint.clone()
1786                        } else {
1787                            text_val.clone()
1788                        },
1789                        color: text_color,
1790                        size: font_px_val,
1791                    });
1792
1793                    // Caret (blink)
1794                    if state.selection.start == state.selection.end && state.caret_visible() {
1795                        let i = byte_to_char_index(&m, state.selection.end);
1796                        let cx_px =
1797                            m.positions.get(i).copied().unwrap_or(0.0) - state.scroll_offset;
1798                        let caret_x_px = inner.x + cx_px.max(0.0);
1799                        scene.nodes.push(SceneNode::Rect {
1800                            rect: repose_core::Rect {
1801                                x: caret_x_px,
1802                                y: inner.y,
1803                                w: dp_to_px(1.0),
1804                                h: inner.h,
1805                            },
1806                            brush: Brush::Solid(mul_alpha_color(Color::WHITE, alpha_accum)),
1807                            radius: 0.0,
1808                        });
1809                    }
1810                    // end inner clip
1811                    scene.nodes.push(SceneNode::PopClip);
1812
1813                    sems.push(SemNode {
1814                        id: v.id,
1815                        role: Role::TextField,
1816                        label: Some(text_val.clone()),
1817                        rect,
1818                        focused: is_focused,
1819                        enabled: true,
1820                    });
1821                } else {
1822                    let font_px_val = font_px(TF_FONT_DP);
1823
1824                    // No state yet: show hint only
1825                    scene.nodes.push(SceneNode::Text {
1826                        rect: repose_core::Rect {
1827                            x: inner.x,
1828                            y: inner.y,
1829                            w: inner.w,
1830                            h: inner.h,
1831                        },
1832                        text: hint.clone(),
1833                        color: mul_alpha_color(Color::from_hex("#666666"), alpha_accum),
1834                        size: font_px_val,
1835                    });
1836                    scene.nodes.push(SceneNode::PopClip);
1837
1838                    sems.push(SemNode {
1839                        id: v.id,
1840                        role: Role::TextField,
1841                        label: Some(hint.clone()),
1842                        rect,
1843                        focused: is_focused,
1844                        enabled: true,
1845                    });
1846                }
1847            }
1848            ViewKind::ScrollV {
1849                on_scroll,
1850                set_viewport_height,
1851                set_content_height,
1852                get_scroll_offset,
1853                set_scroll_offset,
1854            } => {
1855                // Keep hit region as outer rect so scroll works even on padding
1856                hits.push(HitRegion {
1857                    id: v.id,
1858                    rect, // outer
1859                    on_click: None,
1860                    on_scroll: on_scroll.clone(),
1861                    focusable: false,
1862                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1863                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1864                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1865                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1866                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1867                    z_index: v.modifier.z_index,
1868                    on_text_change: None,
1869                    on_text_submit: None,
1870                    tf_state_key: None,
1871                });
1872
1873                // Use the inner content box (after padding) as the true viewport
1874                let vp = content_rect; // already computed above with padding converted to px
1875
1876                if let Some(set_vh) = set_viewport_height {
1877                    set_vh(vp.h.max(0.0));
1878                }
1879
1880                // True content height (use subtree extents per child)
1881                fn subtree_extents(node: taffy::NodeId, t: &TaffyTree<NodeCtx>) -> (f32, f32) {
1882                    let l = t.layout(node).unwrap();
1883                    let mut w = l.size.width;
1884                    let mut h = l.size.height;
1885                    if let Ok(children) = t.children(node) {
1886                        for &ch in children.iter() {
1887                            let cl = t.layout(ch).unwrap();
1888                            let (cw, chh) = subtree_extents(ch, t);
1889                            w = w.max(cl.location.x + cw);
1890                            h = h.max(cl.location.y + chh);
1891                        }
1892                    }
1893                    (w, h)
1894                }
1895                let mut content_h_px = 0.0f32;
1896                for c in &v.children {
1897                    let nid = nodes[&c.id];
1898                    let l = t.layout(nid).unwrap();
1899                    let (_cw, chh) = subtree_extents(nid, t);
1900                    content_h_px = content_h_px.max(l.location.y + chh);
1901                }
1902                if let Some(set_ch) = set_content_height {
1903                    set_ch(content_h_px);
1904                }
1905
1906                // Clip to the inner viewport
1907                scene.nodes.push(SceneNode::PushClip {
1908                    rect: vp,
1909                    radius: 0.0, // inner clip; keep simple (outer border already drawn if any)
1910                });
1911
1912                // Walk children
1913                let hit_start = hits.len();
1914                let scroll_offset_px = if let Some(get) = get_scroll_offset {
1915                    get()
1916                } else {
1917                    0.0
1918                };
1919                let child_offset_px = (base_px.0 + pad_dx, base_px.1 + pad_dy - scroll_offset_px);
1920                for c in &v.children {
1921                    walk(
1922                        c,
1923                        t,
1924                        nodes,
1925                        scene,
1926                        hits,
1927                        sems,
1928                        textfield_states,
1929                        interactions,
1930                        focused,
1931                        child_offset_px,
1932                        alpha_accum,
1933                        text_cache,
1934                        font_px,
1935                    );
1936                }
1937
1938                // Clip descendant hit regions to the viewport
1939                let mut i = hit_start;
1940                while i < hits.len() {
1941                    if let Some(r) = intersect(hits[i].rect, vp) {
1942                        hits[i].rect = r;
1943                        i += 1;
1944                    } else {
1945                        hits.remove(i);
1946                    }
1947                }
1948
1949                // Scrollbar overlay
1950                push_scrollbar_v(
1951                    scene,
1952                    hits,
1953                    interactions,
1954                    v.id,
1955                    vp,
1956                    content_h_px,
1957                    scroll_offset_px,
1958                    v.modifier.z_index,
1959                    set_scroll_offset.clone(),
1960                );
1961
1962                scene.nodes.push(SceneNode::PopClip);
1963                return;
1964            }
1965            ViewKind::ScrollXY {
1966                on_scroll,
1967                set_viewport_width,
1968                set_viewport_height,
1969                set_content_width,
1970                set_content_height,
1971                get_scroll_offset_xy,
1972                set_scroll_offset_xy,
1973            } => {
1974                hits.push(HitRegion {
1975                    id: v.id,
1976                    rect,
1977                    on_click: None,
1978                    on_scroll: on_scroll.clone(),
1979                    focusable: false,
1980                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1981                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1982                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1983                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1984                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1985                    z_index: v.modifier.z_index,
1986                    on_text_change: None,
1987                    on_text_submit: None,
1988                    tf_state_key: None,
1989                });
1990
1991                let vp = content_rect;
1992
1993                if let Some(set_w) = set_viewport_width {
1994                    set_w(vp.w.max(0.0));
1995                }
1996                if let Some(set_h) = set_viewport_height {
1997                    set_h(vp.h.max(0.0));
1998                }
1999
2000                fn subtree_extents(node: taffy::NodeId, t: &TaffyTree<NodeCtx>) -> (f32, f32) {
2001                    let l = t.layout(node).unwrap();
2002                    let mut w = l.size.width;
2003                    let mut h = l.size.height;
2004                    if let Ok(children) = t.children(node) {
2005                        for &ch in children.iter() {
2006                            let cl = t.layout(ch).unwrap();
2007                            let (cw, chh) = subtree_extents(ch, t);
2008                            w = w.max(cl.location.x + cw);
2009                            h = h.max(cl.location.y + chh);
2010                        }
2011                    }
2012                    (w, h)
2013                }
2014                let mut content_w_px = 0.0f32;
2015                let mut content_h_px = 0.0f32;
2016                for c in &v.children {
2017                    let nid = nodes[&c.id];
2018                    let l = t.layout(nid).unwrap();
2019                    let (cw, chh) = subtree_extents(nid, t);
2020                    content_w_px = content_w_px.max(l.location.x + cw);
2021                    content_h_px = content_h_px.max(l.location.y + chh);
2022                }
2023                if let Some(set_cw) = set_content_width {
2024                    set_cw(content_w_px);
2025                }
2026                if let Some(set_ch) = set_content_height {
2027                    set_ch(content_h_px);
2028                }
2029
2030                scene.nodes.push(SceneNode::PushClip {
2031                    rect: vp,
2032                    radius: 0.0,
2033                });
2034
2035                let hit_start = hits.len();
2036                let (ox_px, oy_px) = if let Some(get) = get_scroll_offset_xy {
2037                    get()
2038                } else {
2039                    (0.0, 0.0)
2040                };
2041                let child_offset_px = (base_px.0 + pad_dx - ox_px, base_px.1 + pad_dy - oy_px);
2042                for c in &v.children {
2043                    walk(
2044                        c,
2045                        t,
2046                        nodes,
2047                        scene,
2048                        hits,
2049                        sems,
2050                        textfield_states,
2051                        interactions,
2052                        focused,
2053                        child_offset_px,
2054                        alpha_accum,
2055                        text_cache,
2056                        font_px,
2057                    );
2058                }
2059                // Clip descendant hits to viewport
2060                let mut i = hit_start;
2061                while i < hits.len() {
2062                    if let Some(r) = intersect(hits[i].rect, vp) {
2063                        hits[i].rect = r;
2064                        i += 1;
2065                    } else {
2066                        hits.remove(i);
2067                    }
2068                }
2069
2070                let set_scroll_y: Option<Rc<dyn Fn(f32)>> =
2071                    set_scroll_offset_xy.clone().map(|set_xy| {
2072                        let ox = ox_px; // keep x, move only y
2073                        Rc::new(move |y| set_xy(ox, y)) as Rc<dyn Fn(f32)>
2074                    });
2075
2076                // Scrollbars against inner viewport
2077                push_scrollbar_v(
2078                    scene,
2079                    hits,
2080                    interactions,
2081                    v.id,
2082                    vp,
2083                    content_h_px,
2084                    oy_px,
2085                    v.modifier.z_index,
2086                    set_scroll_y,
2087                );
2088                push_scrollbar_h(
2089                    scene,
2090                    hits,
2091                    interactions,
2092                    v.id,
2093                    vp,
2094                    content_w_px,
2095                    ox_px,
2096                    v.modifier.z_index,
2097                    set_scroll_offset_xy.clone(),
2098                    oy_px,
2099                );
2100
2101                scene.nodes.push(SceneNode::PopClip);
2102                return;
2103            }
2104            ViewKind::Checkbox { checked, on_change } => {
2105                let theme = locals::theme();
2106                // Box at left (20x20 centered vertically)
2107                let box_size_px = dp_to_px(18.0);
2108                let bx = rect.x;
2109                let by = rect.y + (rect.h - box_size_px) * 0.5;
2110                // box bg/border
2111                scene.nodes.push(SceneNode::Rect {
2112                    rect: repose_core::Rect {
2113                        x: bx,
2114                        y: by,
2115                        w: box_size_px,
2116                        h: box_size_px,
2117                    },
2118                    brush: Brush::Solid(if *checked {
2119                        mul_alpha_color(theme.primary, alpha_accum)
2120                    } else {
2121                        mul_alpha_color(theme.surface, alpha_accum)
2122                    }),
2123                    radius: dp_to_px(3.0),
2124                });
2125                scene.nodes.push(SceneNode::Border {
2126                    rect: repose_core::Rect {
2127                        x: bx,
2128                        y: by,
2129                        w: box_size_px,
2130                        h: box_size_px,
2131                    },
2132                    color: mul_alpha_color(theme.outline, alpha_accum),
2133                    width: dp_to_px(1.0),
2134                    radius: dp_to_px(3.0),
2135                });
2136                // checkmark
2137                if *checked {
2138                    scene.nodes.push(SceneNode::Text {
2139                        rect: repose_core::Rect {
2140                            x: bx + dp_to_px(3.0),
2141                            y: rect.y + rect.h * 0.5 - font_px(16.0) * 0.6,
2142                            w: rect.w - (box_size_px + dp_to_px(8.0)),
2143                            h: font_px(16.0),
2144                        },
2145                        text: "✓".to_string(),
2146                        color: mul_alpha_color(theme.on_primary, alpha_accum),
2147                        size: font_px(16.0),
2148                    });
2149                }
2150                // Hit + semantics + focus ring
2151                let toggled = !*checked;
2152                let on_click = on_change.as_ref().map(|cb| {
2153                    let cb = cb.clone();
2154                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
2155                });
2156                hits.push(HitRegion {
2157                    id: v.id,
2158                    rect,
2159                    on_click,
2160                    on_scroll: None,
2161                    focusable: true,
2162                    on_pointer_down: v.modifier.on_pointer_down.clone(),
2163                    on_pointer_move: v.modifier.on_pointer_move.clone(),
2164                    on_pointer_up: v.modifier.on_pointer_up.clone(),
2165                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2166                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2167                    z_index: v.modifier.z_index,
2168                    on_text_change: None,
2169                    on_text_submit: None,
2170                    tf_state_key: None,
2171                });
2172                sems.push(SemNode {
2173                    id: v.id,
2174                    role: Role::Checkbox,
2175                    label: None,
2176                    rect,
2177                    focused: is_focused,
2178                    enabled: true,
2179                });
2180                if is_focused {
2181                    scene.nodes.push(SceneNode::Border {
2182                        rect,
2183                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
2184                        width: dp_to_px(2.0),
2185                        radius: v
2186                            .modifier
2187                            .clip_rounded
2188                            .map(dp_to_px)
2189                            .unwrap_or(dp_to_px(6.0)),
2190                    });
2191                }
2192            }
2193
2194            ViewKind::RadioButton {
2195                selected,
2196                on_select,
2197            } => {
2198                let theme = locals::theme();
2199                let d_px = dp_to_px(18.0);
2200                let cx = rect.x;
2201                let cy = rect.y + (rect.h - d_px) * 0.5;
2202
2203                // outer circle (rounded rect as circle)
2204                scene.nodes.push(SceneNode::Border {
2205                    rect: repose_core::Rect {
2206                        x: cx,
2207                        y: cy,
2208                        w: d_px,
2209                        h: d_px,
2210                    },
2211                    color: mul_alpha_color(theme.outline, alpha_accum),
2212                    width: dp_to_px(1.5),
2213                    radius: d_px * 0.5,
2214                });
2215                // inner dot if selected
2216                if *selected {
2217                    scene.nodes.push(SceneNode::Rect {
2218                        rect: repose_core::Rect {
2219                            x: cx + dp_to_px(4.0),
2220                            y: cy + dp_to_px(4.0),
2221                            w: d_px - dp_to_px(8.0),
2222                            h: d_px - dp_to_px(8.0),
2223                        },
2224                        brush: Brush::Solid(mul_alpha_color(theme.primary, alpha_accum)),
2225                        radius: (d_px - dp_to_px(8.0)) * 0.5,
2226                    });
2227                }
2228
2229                hits.push(HitRegion {
2230                    id: v.id,
2231                    rect,
2232                    on_click: on_select.clone(),
2233                    on_scroll: None,
2234                    focusable: true,
2235                    on_pointer_down: v.modifier.on_pointer_down.clone(),
2236                    on_pointer_move: v.modifier.on_pointer_move.clone(),
2237                    on_pointer_up: v.modifier.on_pointer_up.clone(),
2238                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2239                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2240                    z_index: v.modifier.z_index,
2241                    on_text_change: None,
2242                    on_text_submit: None,
2243                    tf_state_key: None,
2244                });
2245                sems.push(SemNode {
2246                    id: v.id,
2247                    role: Role::RadioButton,
2248                    label: None,
2249                    rect,
2250                    focused: is_focused,
2251                    enabled: true,
2252                });
2253                if is_focused {
2254                    scene.nodes.push(SceneNode::Border {
2255                        rect,
2256                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
2257                        width: dp_to_px(2.0),
2258                        radius: v
2259                            .modifier
2260                            .clip_rounded
2261                            .map(dp_to_px)
2262                            .unwrap_or(dp_to_px(6.0)),
2263                    });
2264                }
2265            }
2266
2267            ViewKind::Switch { checked, on_change } => {
2268                let theme = locals::theme();
2269                // track 46x26, knob 22x22
2270                let track_w_px = dp_to_px(46.0);
2271                let track_h_px = dp_to_px(26.0);
2272                let tx = rect.x;
2273                let ty = rect.y + (rect.h - track_h_px) * 0.5;
2274                let knob_px = dp_to_px(22.0);
2275                let on_col = theme.primary;
2276                let off_col = Color::from_hex("#333333");
2277
2278                // track
2279                scene.nodes.push(SceneNode::Rect {
2280                    rect: repose_core::Rect {
2281                        x: tx,
2282                        y: ty,
2283                        w: track_w_px,
2284                        h: track_h_px,
2285                    },
2286                    brush: Brush::Solid(if *checked {
2287                        mul_alpha_color(on_col, alpha_accum)
2288                    } else {
2289                        mul_alpha_color(off_col, alpha_accum)
2290                    }),
2291                    radius: track_h_px * 0.5,
2292                });
2293                // knob position
2294                let kx = if *checked {
2295                    tx + track_w_px - knob_px - dp_to_px(2.0)
2296                } else {
2297                    tx + dp_to_px(2.0)
2298                };
2299                let ky = ty + (track_h_px - knob_px) * 0.5;
2300                scene.nodes.push(SceneNode::Rect {
2301                    rect: repose_core::Rect {
2302                        x: kx,
2303                        y: ky,
2304                        w: knob_px,
2305                        h: knob_px,
2306                    },
2307                    brush: Brush::Solid(mul_alpha_color(Color::from_hex("#EEEEEE"), alpha_accum)),
2308                    radius: knob_px * 0.5,
2309                });
2310                scene.nodes.push(SceneNode::Border {
2311                    rect: repose_core::Rect {
2312                        x: kx,
2313                        y: ky,
2314                        w: knob_px,
2315                        h: knob_px,
2316                    },
2317                    color: mul_alpha_color(theme.outline, alpha_accum),
2318                    width: dp_to_px(1.0),
2319                    radius: knob_px * 0.5,
2320                });
2321
2322                let toggled = !*checked;
2323                let on_click = on_change.as_ref().map(|cb| {
2324                    let cb = cb.clone();
2325                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
2326                });
2327                hits.push(HitRegion {
2328                    id: v.id,
2329                    rect,
2330                    on_click,
2331                    on_scroll: None,
2332                    focusable: true,
2333                    on_pointer_down: v.modifier.on_pointer_down.clone(),
2334                    on_pointer_move: v.modifier.on_pointer_move.clone(),
2335                    on_pointer_up: v.modifier.on_pointer_up.clone(),
2336                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2337                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2338                    z_index: v.modifier.z_index,
2339                    on_text_change: None,
2340                    on_text_submit: None,
2341                    tf_state_key: None,
2342                });
2343                sems.push(SemNode {
2344                    id: v.id,
2345                    role: Role::Switch,
2346                    label: None,
2347                    rect,
2348                    focused: is_focused,
2349                    enabled: true,
2350                });
2351                if is_focused {
2352                    scene.nodes.push(SceneNode::Border {
2353                        rect,
2354                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
2355                        width: dp_to_px(2.0),
2356                        radius: v
2357                            .modifier
2358                            .clip_rounded
2359                            .map(dp_to_px)
2360                            .unwrap_or(dp_to_px(6.0)),
2361                    });
2362                }
2363            }
2364            ViewKind::Slider {
2365                value,
2366                min,
2367                max,
2368                step,
2369                on_change,
2370            } => {
2371                let theme = locals::theme();
2372                // Layout: [track | label]
2373                let track_h_px = dp_to_px(4.0);
2374                let knob_d_px = dp_to_px(20.0);
2375                let _gap_px = dp_to_px(8.0);
2376                let label_x = rect.x + rect.w * 0.6; // simple split: 60% track, 40% label
2377                let track_x = rect.x;
2378                let track_w_px = (label_x - track_x).max(dp_to_px(60.0));
2379                let cy = rect.y + rect.h * 0.5;
2380
2381                // Track
2382                scene.nodes.push(SceneNode::Rect {
2383                    rect: repose_core::Rect {
2384                        x: track_x,
2385                        y: cy - track_h_px * 0.5,
2386                        w: track_w_px,
2387                        h: track_h_px,
2388                    },
2389                    brush: Brush::Solid(mul_alpha_color(Color::from_hex("#333333"), alpha_accum)),
2390                    radius: track_h_px * 0.5,
2391                });
2392
2393                // Knob position
2394                let t = clamp01(norm(*value, *min, *max));
2395                let kx = track_x + t * track_w_px;
2396                scene.nodes.push(SceneNode::Rect {
2397                    rect: repose_core::Rect {
2398                        x: kx - knob_d_px * 0.5,
2399                        y: cy - knob_d_px * 0.5,
2400                        w: knob_d_px,
2401                        h: knob_d_px,
2402                    },
2403                    brush: Brush::Solid(mul_alpha_color(theme.surface, alpha_accum)),
2404                    radius: knob_d_px * 0.5,
2405                });
2406                scene.nodes.push(SceneNode::Border {
2407                    rect: repose_core::Rect {
2408                        x: kx - knob_d_px * 0.5,
2409                        y: cy - knob_d_px * 0.5,
2410                        w: knob_d_px,
2411                        h: knob_d_px,
2412                    },
2413                    color: mul_alpha_color(theme.outline, alpha_accum),
2414                    width: dp_to_px(1.0),
2415                    radius: knob_d_px * 0.5,
2416                });
2417
2418                // Interactions
2419                let on_change_cb: Option<Rc<dyn Fn(f32)>> = on_change.as_ref().cloned();
2420                let minv = *min;
2421                let maxv = *max;
2422                let stepv = *step;
2423
2424                // per-hit-region current value (wheel deltas accumulate within a frame)
2425                let current = Rc::new(RefCell::new(*value));
2426
2427                // pointer mapping closure (in global coords, px)
2428                let update_at = {
2429                    let on_change_cb = on_change_cb.clone();
2430                    let current = current.clone();
2431                    Rc::new(move |px_pos: f32| {
2432                        let tt = clamp01((px_pos - track_x) / track_w_px);
2433                        let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
2434                        *current.borrow_mut() = v;
2435                        if let Some(cb) = &on_change_cb {
2436                            cb(v);
2437                        }
2438                    })
2439                };
2440
2441                // on_pointer_down: update once at press
2442                let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2443                    let f = update_at.clone();
2444                    Rc::new(move |pe| {
2445                        f(pe.position.x);
2446                    })
2447                };
2448
2449                // on_pointer_move: platform only delivers here while captured
2450                let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2451                    let f = update_at.clone();
2452                    Rc::new(move |pe| {
2453                        f(pe.position.x);
2454                    })
2455                };
2456
2457                // on_pointer_up: no-op
2458                let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
2459
2460                // Mouse wheel nudge: accumulate via 'current'
2461                let on_scroll = {
2462                    let on_change_cb = on_change_cb.clone();
2463                    let current = current.clone();
2464                    Rc::new(move |d: Vec2| -> Vec2 {
2465                        let base = *current.borrow();
2466                        let delta = stepv.unwrap_or((maxv - minv) * 0.01);
2467                        // wheel-up (negative y) increases
2468                        let dir = if d.y.is_sign_negative() { 1.0 } else { -1.0 };
2469                        let new_v = snap_step(base + dir * delta, stepv, minv, maxv);
2470                        *current.borrow_mut() = new_v;
2471                        if let Some(cb) = &on_change_cb {
2472                            cb(new_v);
2473                        }
2474                        Vec2 { x: d.x, y: 0.0 } // we consumed all y, pass x through
2475                    })
2476                };
2477
2478                // Register move handler only while pressed so hover doesn't change value
2479                hits.push(HitRegion {
2480                    id: v.id,
2481                    rect,
2482                    on_click: None,
2483                    on_scroll: Some(on_scroll),
2484                    focusable: true,
2485                    on_pointer_down: Some(on_pd),
2486                    on_pointer_move: if is_pressed { Some(on_pm) } else { None },
2487                    on_pointer_up: Some(on_pu),
2488                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2489                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2490                    z_index: v.modifier.z_index,
2491                    on_text_change: None,
2492                    on_text_submit: None,
2493                    tf_state_key: None,
2494                });
2495
2496                sems.push(SemNode {
2497                    id: v.id,
2498                    role: Role::Slider,
2499                    label: None,
2500                    rect,
2501                    focused: is_focused,
2502                    enabled: true,
2503                });
2504                if is_focused {
2505                    scene.nodes.push(SceneNode::Border {
2506                        rect,
2507                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
2508                        width: dp_to_px(2.0),
2509                        radius: v
2510                            .modifier
2511                            .clip_rounded
2512                            .map(dp_to_px)
2513                            .unwrap_or(dp_to_px(6.0)),
2514                    });
2515                }
2516            }
2517            ViewKind::RangeSlider {
2518                start,
2519                end,
2520                min,
2521                max,
2522                step,
2523                on_change,
2524            } => {
2525                let theme = locals::theme();
2526                let track_h_px = dp_to_px(4.0);
2527                let knob_d_px = dp_to_px(20.0);
2528                let _gap_px = dp_to_px(8.0);
2529                let label_x = rect.x + rect.w * 0.6;
2530                let track_x = rect.x;
2531                let track_w_px = (label_x - track_x).max(dp_to_px(80.0));
2532                let cy = rect.y + rect.h * 0.5;
2533
2534                // Track
2535                scene.nodes.push(SceneNode::Rect {
2536                    rect: repose_core::Rect {
2537                        x: track_x,
2538                        y: cy - track_h_px * 0.5,
2539                        w: track_w_px,
2540                        h: track_h_px,
2541                    },
2542                    brush: Brush::Solid(mul_alpha_color(Color::from_hex("#333333"), alpha_accum)),
2543                    radius: track_h_px * 0.5,
2544                });
2545
2546                // Positions
2547                let t0 = clamp01(norm(*start, *min, *max));
2548                let t1 = clamp01(norm(*end, *min, *max));
2549                let k0x = track_x + t0 * track_w_px;
2550                let k1x = track_x + t1 * track_w_px;
2551
2552                // Range fill
2553                scene.nodes.push(SceneNode::Rect {
2554                    rect: repose_core::Rect {
2555                        x: k0x.min(k1x),
2556                        y: cy - track_h_px * 0.5,
2557                        w: (k1x - k0x).abs(),
2558                        h: track_h_px,
2559                    },
2560                    brush: Brush::Solid(mul_alpha_color(theme.primary, alpha_accum)),
2561                    radius: track_h_px * 0.5,
2562                });
2563
2564                // Knobs
2565                for &kx in &[k0x, k1x] {
2566                    scene.nodes.push(SceneNode::Rect {
2567                        rect: repose_core::Rect {
2568                            x: kx - knob_d_px * 0.5,
2569                            y: cy - knob_d_px * 0.5,
2570                            w: knob_d_px,
2571                            h: knob_d_px,
2572                        },
2573                        brush: Brush::Solid(mul_alpha_color(theme.surface, alpha_accum)),
2574                        radius: knob_d_px * 0.5,
2575                    });
2576                    scene.nodes.push(SceneNode::Border {
2577                        rect: repose_core::Rect {
2578                            x: kx - knob_d_px * 0.5,
2579                            y: cy - knob_d_px * 0.5,
2580                            w: knob_d_px,
2581                            h: knob_d_px,
2582                        },
2583                        color: mul_alpha_color(theme.outline, alpha_accum),
2584                        width: dp_to_px(1.0),
2585                        radius: knob_d_px * 0.5,
2586                    });
2587                }
2588
2589                // Interaction
2590                let on_change_cb = on_change.as_ref().cloned();
2591                let minv = *min;
2592                let maxv = *max;
2593                let stepv = *step;
2594                let start_val = *start;
2595                let end_val = *end;
2596
2597                // which thumb is active during drag: Some(0) or Some(1)
2598                let active = Rc::new(RefCell::new(None::<u8>));
2599
2600                // update for current active thumb; does nothing if None
2601                let update = {
2602                    let active = active.clone();
2603                    let on_change_cb = on_change_cb.clone();
2604                    Rc::new(move |px_pos: f32| {
2605                        if let Some(thumb) = *active.borrow() {
2606                            let tt = clamp01((px_pos - track_x) / track_w_px);
2607                            let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
2608                            match thumb {
2609                                0 => {
2610                                    let new_start = v.min(end_val).min(maxv).max(minv);
2611                                    if let Some(cb) = &on_change_cb {
2612                                        cb(new_start, end_val);
2613                                    }
2614                                }
2615                                _ => {
2616                                    let new_end = v.max(start_val).max(minv).min(maxv);
2617                                    if let Some(cb) = &on_change_cb {
2618                                        cb(start_val, new_end);
2619                                    }
2620                                }
2621                            }
2622                        }
2623                    })
2624                };
2625
2626                // on_pointer_down: choose nearest thumb and update once
2627                let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2628                    let active = active.clone();
2629                    let update = update.clone();
2630                    // snapshot thumb positions for hit decision
2631                    let k0x0 = k0x;
2632                    let k1x0 = k1x;
2633                    Rc::new(move |pe| {
2634                        let px_pos = pe.position.x;
2635                        let d0 = (px_pos - k0x0).abs();
2636                        let d1 = (px_pos - k1x0).abs();
2637                        *active.borrow_mut() = Some(if d0 <= d1 { 0 } else { 1 });
2638                        update(px_pos);
2639                    })
2640                };
2641
2642                // on_pointer_move: update only while a thumb is active
2643                let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2644                    let active = active.clone();
2645                    let update = update.clone();
2646                    Rc::new(move |pe| {
2647                        if active.borrow().is_some() {
2648                            update(pe.position.x);
2649                        }
2650                    })
2651                };
2652
2653                // on_pointer_up: clear active thumb
2654                let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2655                    let active = active.clone();
2656                    Rc::new(move |_pe| {
2657                        *active.borrow_mut() = None;
2658                    })
2659                };
2660
2661                hits.push(HitRegion {
2662                    id: v.id,
2663                    rect,
2664                    on_click: None,
2665                    on_scroll: None,
2666                    focusable: true,
2667                    on_pointer_down: Some(on_pd),
2668                    on_pointer_move: Some(on_pm),
2669                    on_pointer_up: Some(on_pu),
2670                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2671                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2672                    z_index: v.modifier.z_index,
2673                    on_text_change: None,
2674                    on_text_submit: None,
2675                    tf_state_key: None,
2676                });
2677                sems.push(SemNode {
2678                    id: v.id,
2679                    role: Role::Slider,
2680                    label: None,
2681                    rect,
2682                    focused: is_focused,
2683                    enabled: true,
2684                });
2685                if is_focused {
2686                    scene.nodes.push(SceneNode::Border {
2687                        rect,
2688                        color: mul_alpha_color(locals::theme().focus, alpha_accum),
2689                        width: dp_to_px(2.0),
2690                        radius: v
2691                            .modifier
2692                            .clip_rounded
2693                            .map(dp_to_px)
2694                            .unwrap_or(dp_to_px(6.0)),
2695                    });
2696                }
2697            }
2698            ViewKind::ProgressBar {
2699                value,
2700                min,
2701                max,
2702                circular: _,
2703            } => {
2704                let theme = locals::theme();
2705                let track_h_px = dp_to_px(6.0);
2706                let gap_px = dp_to_px(8.0);
2707                let label_w_split_px = rect.w * 0.6;
2708                let track_x = rect.x;
2709                let track_w_px = (label_w_split_px - track_x).max(dp_to_px(60.0));
2710                let cy = rect.y + rect.h * 0.5;
2711
2712                scene.nodes.push(SceneNode::Rect {
2713                    rect: repose_core::Rect {
2714                        x: track_x,
2715                        y: cy - track_h_px * 0.5,
2716                        w: track_w_px,
2717                        h: track_h_px,
2718                    },
2719                    brush: Brush::Solid(mul_alpha_color(Color::from_hex("#333333"), alpha_accum)),
2720                    radius: track_h_px * 0.5,
2721                });
2722
2723                let t = clamp01(norm(*value, *min, *max));
2724                scene.nodes.push(SceneNode::Rect {
2725                    rect: repose_core::Rect {
2726                        x: track_x,
2727                        y: cy - track_h_px * 0.5,
2728                        w: track_w_px * t,
2729                        h: track_h_px,
2730                    },
2731                    brush: Brush::Solid(mul_alpha_color(theme.primary, alpha_accum)),
2732                    radius: track_h_px * 0.5,
2733                });
2734
2735                scene.nodes.push(SceneNode::Text {
2736                    rect: repose_core::Rect {
2737                        x: rect.x + label_w_split_px + gap_px,
2738                        y: rect.y + rect.h * 0.5 - font_px(16.0) * 0.6,
2739                        w: rect.w - (label_w_split_px + gap_px),
2740                        h: font_px(16.0),
2741                    },
2742                    text: format!("{:.0}%", t * 100.0),
2743                    color: mul_alpha_color(theme.on_surface, alpha_accum),
2744                    size: font_px(16.0),
2745                });
2746
2747                sems.push(SemNode {
2748                    id: v.id,
2749                    role: Role::ProgressBar,
2750                    label: None,
2751                    rect,
2752                    focused: is_focused,
2753                    enabled: true,
2754                });
2755            }
2756            _ => {}
2757        }
2758
2759        for c in &v.children {
2760            walk(
2761                c,
2762                t,
2763                nodes,
2764                scene,
2765                hits,
2766                sems,
2767                textfield_states,
2768                interactions,
2769                focused,
2770                base_px,
2771                alpha_accum,
2772                text_cache,
2773                font_px,
2774            );
2775        }
2776
2777        if v.modifier.transform.is_some() {
2778            scene.nodes.push(SceneNode::PopTransform);
2779        }
2780    }
2781
2782    let font_px = |dp_font: f32| dp_to_px(dp_font) * locals::text_scale().0;
2783
2784    // Start with zero offset
2785    walk(
2786        &root,
2787        &taffy,
2788        &nodes_map,
2789        &mut scene,
2790        &mut hits,
2791        &mut sems,
2792        textfield_states,
2793        interactions,
2794        focused,
2795        (0.0, 0.0),
2796        1.0,
2797        &text_cache,
2798        &font_px,
2799    );
2800
2801    // Ensure visual order: low z_index first. Topmost will be found by iter().rev().
2802    hits.sort_by(|a, b| a.z_index.partial_cmp(&b.z_index).unwrap_or(Ordering::Equal));
2803
2804    (scene, hits, sems)
2805}
2806
2807/// Method styling
2808pub trait TextStyle {
2809    fn color(self, c: Color) -> View;
2810    fn size(self, px: f32) -> View;
2811    fn max_lines(self, n: usize) -> View;
2812    fn single_line(self) -> View;
2813    fn overflow_ellipsize(self) -> View;
2814    fn overflow_clip(self) -> View;
2815    fn overflow_visible(self) -> View;
2816}
2817impl TextStyle for View {
2818    fn color(mut self, c: Color) -> View {
2819        if let ViewKind::Text {
2820            color: text_color, ..
2821        } = &mut self.kind
2822        {
2823            *text_color = c;
2824        }
2825        self
2826    }
2827    fn size(mut self, dp_font: f32) -> View {
2828        if let ViewKind::Text {
2829            font_size: text_size_dp,
2830            ..
2831        } = &mut self.kind
2832        {
2833            *text_size_dp = dp_font;
2834        }
2835        self
2836    }
2837    fn max_lines(mut self, n: usize) -> View {
2838        if let ViewKind::Text {
2839            max_lines,
2840            soft_wrap,
2841            ..
2842        } = &mut self.kind
2843        {
2844            *max_lines = Some(n);
2845            *soft_wrap = true;
2846        }
2847        self
2848    }
2849    fn single_line(mut self) -> View {
2850        if let ViewKind::Text {
2851            soft_wrap,
2852            max_lines,
2853            ..
2854        } = &mut self.kind
2855        {
2856            *soft_wrap = false;
2857            *max_lines = Some(1);
2858        }
2859        self
2860    }
2861    fn overflow_ellipsize(mut self) -> View {
2862        if let ViewKind::Text { overflow, .. } = &mut self.kind {
2863            *overflow = TextOverflow::Ellipsis;
2864        }
2865        self
2866    }
2867    fn overflow_clip(mut self) -> View {
2868        if let ViewKind::Text { overflow, .. } = &mut self.kind {
2869            *overflow = TextOverflow::Clip;
2870        }
2871        self
2872    }
2873    fn overflow_visible(mut self) -> View {
2874        if let ViewKind::Text { overflow, .. } = &mut self.kind {
2875            *overflow = TextOverflow::Visible;
2876        }
2877        self
2878    }
2879}