repose_ui/
lib.rs

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