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: false,
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        s
666    }
667
668    fn build_node(
669        v: &View,
670        t: &mut TaffyTree<NodeCtx>,
671        nodes_map: &mut HashMap<ViewId, taffy::NodeId>,
672    ) -> taffy::NodeId {
673        // We'll inject px() at call-site (need locals access); this function
674        // is called from a scope that has the helper closure.
675        let px_helper = |dp_val: f32| dp_to_px(dp_val);
676
677        let mut style = style_from_modifier(&v.modifier, &v.kind, &px_helper);
678
679        if v.modifier.grid_col_span.is_some() || v.modifier.grid_row_span.is_some() {
680            use taffy::prelude::{GridPlacement, Line};
681
682            let col_span = v.modifier.grid_col_span.unwrap_or(1).max(1);
683            let row_span = v.modifier.grid_row_span.unwrap_or(1).max(1);
684
685            style.grid_column = Line {
686                start: GridPlacement::Auto,
687                end: GridPlacement::Span(col_span),
688            };
689            style.grid_row = Line {
690                start: GridPlacement::Auto,
691                end: GridPlacement::Span(row_span),
692            };
693        }
694
695        let children: Vec<_> = v
696            .children
697            .iter()
698            .map(|c| build_node(c, t, nodes_map))
699            .collect();
700
701        let node = match &v.kind {
702            ViewKind::Text {
703                text,
704                font_size: font_dp,
705                soft_wrap,
706                max_lines,
707                overflow,
708                ..
709            } => t
710                .new_leaf_with_context(
711                    style,
712                    NodeCtx::Text {
713                        text: text.clone(),
714                        font_dp: *font_dp,
715                        soft_wrap: *soft_wrap,
716                        max_lines: *max_lines,
717                        overflow: *overflow,
718                    },
719                )
720                .unwrap(),
721            ViewKind::Button { .. } => {
722                let children: Vec<_> = v
723                    .children
724                    .iter()
725                    .map(|c| build_node(c, t, nodes_map))
726                    .collect();
727                let n = t.new_with_children(style, &children).unwrap();
728                t.set_node_context(n, Some(NodeCtx::Container)).ok();
729                n
730            }
731            ViewKind::TextField { .. } => {
732                t.new_leaf_with_context(style, NodeCtx::TextField).unwrap()
733            }
734            ViewKind::Image { .. } => t.new_leaf_with_context(style, NodeCtx::Container).unwrap(),
735            ViewKind::Checkbox { .. } => t
736                .new_leaf_with_context(style, NodeCtx::Checkbox {})
737                .unwrap(),
738            ViewKind::RadioButton { .. } => {
739                t.new_leaf_with_context(style, NodeCtx::Radio {}).unwrap()
740            }
741            ViewKind::Switch { .. } => t.new_leaf_with_context(style, NodeCtx::Switch {}).unwrap(),
742            ViewKind::Slider { .. } => t.new_leaf_with_context(style, NodeCtx::Slider).unwrap(),
743            ViewKind::RangeSlider { .. } => t.new_leaf_with_context(style, NodeCtx::Range).unwrap(),
744            ViewKind::ProgressBar { .. } => {
745                t.new_leaf_with_context(style, NodeCtx::Progress).unwrap()
746            }
747            ViewKind::ScrollV { .. } => {
748                let children: Vec<_> = v
749                    .children
750                    .iter()
751                    .map(|c| build_node(c, t, nodes_map))
752                    .collect();
753
754                let n = t.new_with_children(style, &children).unwrap();
755                t.set_node_context(n, Some(NodeCtx::ScrollContainer)).ok();
756                n
757            }
758            _ => {
759                let n = t.new_with_children(style, &children).unwrap();
760                t.set_node_context(n, Some(NodeCtx::Container)).ok();
761                n
762            }
763        };
764
765        nodes_map.insert(v.id, node);
766        node
767    }
768
769    let root_node = build_node(&root, &mut taffy, &mut nodes_map);
770
771    {
772        let mut rs = taffy.style(root_node).unwrap().clone();
773        rs.size.width = length(size_px_u32.0 as f32);
774        rs.size.height = length(size_px_u32.1 as f32);
775        taffy.set_style(root_node, rs).unwrap();
776    }
777
778    let available = taffy::geometry::Size {
779        width: AvailableSpace::Definite(size_px_u32.0 as f32),
780        height: AvailableSpace::Definite(size_px_u32.1 as f32),
781    };
782
783    // Measure function for intrinsic content
784    taffy
785        .compute_layout_with_measure(root_node, available, |known, avail, node, ctx, _style| {
786            match ctx {
787                Some(NodeCtx::Text {
788                    text,
789                    font_dp,
790                    soft_wrap,
791                    max_lines,
792                    overflow,
793                }) => {
794                    // Apply density + text scale in measure so paint matches exactly
795                    let size_px_val = font_px(*font_dp);
796                    let line_h_px_val = size_px_val * 1.3;
797
798                    // Content-hugging width by default (unless caller set known.width).
799                    let approx_w_px = text.len() as f32 * size_px_val * 0.6; // rough estimate (glyph-width-ish)
800                    let measured_w_px = known.width.unwrap_or(approx_w_px);
801
802                    // Wrap width in px if soft wrap enabled
803                    let wrap_w_px = if *soft_wrap {
804                        match avail.width {
805                            AvailableSpace::Definite(w) => w,
806                            _ => measured_w_px,
807                        }
808                    } else {
809                        measured_w_px
810                    };
811
812                    // Produce final lines once and cache
813                    let lines_vec: Vec<String> = if *soft_wrap {
814                        let (ls, _trunc) =
815                            repose_text::wrap_lines(text, size_px_val, wrap_w_px, *max_lines, true);
816                        ls
817                    } else {
818                        match overflow {
819                            TextOverflow::Ellipsis => {
820                                vec![repose_text::ellipsize_line(text, size_px_val, wrap_w_px)]
821                            }
822                            _ => vec![text.clone()],
823                        }
824                    };
825                    text_cache.insert(
826                        node,
827                        TextLayout {
828                            lines: lines_vec.clone(),
829                            size_px: size_px_val,
830                            line_h_px: line_h_px_val,
831                        },
832                    );
833                    let n_lines = lines_vec.len().max(1);
834
835                    taffy::geometry::Size {
836                        width: measured_w_px,
837                        height: line_h_px_val * n_lines as f32,
838                    }
839                }
840                Some(NodeCtx::Button { label }) => taffy::geometry::Size {
841                    width: (label.len() as f32 * font_px(16.0) * 0.6) + px(24.0),
842                    height: px(36.0),
843                },
844                Some(NodeCtx::TextField) => taffy::geometry::Size {
845                    width: known.width.unwrap_or(px(220.0)),
846                    height: px(36.0),
847                },
848                Some(NodeCtx::Checkbox { .. }) => taffy::geometry::Size {
849                    width: known.width.unwrap_or(px(24.0)),
850                    height: px(24.0),
851                },
852                Some(NodeCtx::Radio { .. }) => taffy::geometry::Size {
853                    width: known.width.unwrap_or(px(18.0)),
854                    height: px(18.0),
855                },
856                Some(NodeCtx::Switch { .. }) => taffy::geometry::Size {
857                    width: known.width.unwrap_or(px(46.0)),
858                    height: px(28.0),
859                },
860                Some(NodeCtx::Slider { .. }) => taffy::geometry::Size {
861                    width: known.width.unwrap_or(px(200.0)),
862                    height: px(28.0),
863                },
864                Some(NodeCtx::Range { .. }) => taffy::geometry::Size {
865                    width: known.width.unwrap_or(px(220.0)),
866                    height: px(28.0),
867                },
868                Some(NodeCtx::Progress { .. }) => taffy::geometry::Size {
869                    width: known.width.unwrap_or(px(200.0)),
870                    height: px(12.0),
871                },
872                Some(NodeCtx::ScrollContainer) | Some(NodeCtx::Container) | None => {
873                    taffy::geometry::Size::ZERO
874                }
875            }
876        })
877        .unwrap();
878
879    // eprintln!(
880    //     "win {:?}x{:?} root {:?}",
881    //     size_px_u32.0,
882    //     size_px_u32.1,
883    //     taffy.layout(root_node).unwrap().size
884    // );
885
886    fn layout_of(node: taffy::NodeId, t: &TaffyTree<impl Clone>) -> repose_core::Rect {
887        let l = t.layout(node).unwrap();
888        repose_core::Rect {
889            x: l.location.x,
890            y: l.location.y,
891            w: l.size.width,
892            h: l.size.height,
893        }
894    }
895
896    fn add_offset(mut r: repose_core::Rect, off: (f32, f32)) -> repose_core::Rect {
897        r.x += off.0;
898        r.y += off.1;
899        r
900    }
901
902    // Rect intersection helper for hit clipping
903    fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> Option<repose_core::Rect> {
904        let x0 = a.x.max(b.x);
905        let y0 = a.y.max(b.y);
906        let x1 = (a.x + a.w).min(b.x + b.w);
907        let y1 = (a.y + a.h).min(b.y + b.h);
908        let w = (x1 - x0).max(0.0);
909        let h = (y1 - y0).max(0.0);
910        if w <= 0.0 || h <= 0.0 {
911            None
912        } else {
913            Some(repose_core::Rect { x: x0, y: y0, w, h })
914        }
915    }
916
917    fn clamp01(x: f32) -> f32 {
918        x.max(0.0).min(1.0)
919    }
920    fn norm(value: f32, min: f32, max: f32) -> f32 {
921        if max > min {
922            (value - min) / (max - min)
923        } else {
924            0.0
925        }
926    }
927    fn denorm(t: f32, min: f32, max: f32) -> f32 {
928        min + t * (max - min)
929    }
930    fn snap_step(v: f32, step: Option<f32>, min: f32, max: f32) -> f32 {
931        match step {
932            Some(s) if s > 0.0 => {
933                let k = ((v - min) / s).round();
934                (min + k * s).clamp(min, max)
935            }
936            _ => v.clamp(min, max),
937        }
938    }
939    fn mul_alpha(c: Color, a: f32) -> Color {
940        let mut out = c;
941        let na = ((c.3 as f32) * a).clamp(0.0, 255.0) as u8;
942        out.3 = na;
943        out
944    }
945    // draws scrollbar and registers their drag hit regions (both)
946    fn push_scrollbar_v(
947        scene: &mut Scene,
948        hits: &mut Vec<HitRegion>,
949        interactions: &Interactions,
950        view_id: u64,
951        vp: crate::Rect,
952        content_h_px: f32,
953        off_y_px: f32,
954        z: f32,
955        set_scroll_offset: Option<Rc<dyn Fn(f32)>>,
956    ) {
957        if content_h_px <= vp.h + 0.5 {
958            return;
959        }
960        let thickness_px = dp_to_px(6.0);
961        let margin_px = dp_to_px(2.0);
962        let min_thumb_px = dp_to_px(24.0);
963        let th = locals::theme();
964
965        // Track geometry (inset inside viewport)
966        let track_x = vp.x + vp.w - margin_px - thickness_px;
967        let track_y = vp.y + margin_px;
968        let track_h = (vp.h - 2.0 * margin_px).max(0.0);
969
970        // Thumb geometry from content ratio
971        let ratio = (vp.h / content_h_px).clamp(0.0, 1.0);
972        let thumb_h = (track_h * ratio).clamp(min_thumb_px, track_h);
973        let denom = (content_h_px - vp.h).max(1.0);
974        let tpos = (off_y_px / denom).clamp(0.0, 1.0);
975        let max_pos = (track_h - thumb_h).max(0.0);
976        let thumb_y = track_y + tpos * max_pos;
977
978        scene.nodes.push(SceneNode::Rect {
979            rect: crate::Rect {
980                x: track_x,
981                y: track_y,
982                w: thickness_px,
983                h: track_h,
984            },
985            color: th.scrollbar_track,
986            radius: thickness_px * 0.5,
987        });
988        scene.nodes.push(SceneNode::Rect {
989            rect: crate::Rect {
990                x: track_x,
991                y: thumb_y,
992                w: thickness_px,
993                h: thumb_h,
994            },
995            color: th.scrollbar_thumb,
996            radius: thickness_px * 0.5,
997        });
998        if let Some(setter) = set_scroll_offset {
999            let thumb_id: u64 = view_id ^ 0x8000_0001;
1000            let map_to_off = Rc::new(move |py_px: f32| -> f32 {
1001                let denom = (content_h_px - vp.h).max(1.0);
1002                let max_pos = (track_h - thumb_h).max(0.0);
1003                let pos = ((py_px - track_y) - thumb_h * 0.5).clamp(0.0, max_pos);
1004                let t = if max_pos > 0.0 { pos / max_pos } else { 0.0 };
1005                t * denom
1006            });
1007            let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1008                let setter = setter.clone();
1009                let map = map_to_off.clone();
1010                Rc::new(move |pe| setter(map(pe.position.y)))
1011            };
1012            let on_pm: Option<Rc<dyn Fn(repose_core::input::PointerEvent)>> =
1013                if interactions.pressed.contains(&thumb_id) {
1014                    let setter = setter.clone();
1015                    let map = map_to_off.clone();
1016                    Some(Rc::new(move |pe| setter(map(pe.position.y))))
1017                } else {
1018                    None
1019                };
1020            let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
1021            hits.push(HitRegion {
1022                id: thumb_id,
1023                rect: crate::Rect {
1024                    x: track_x,
1025                    y: thumb_y,
1026                    w: thickness_px,
1027                    h: thumb_h,
1028                },
1029                on_click: None,
1030                on_scroll: None,
1031                focusable: false,
1032                on_pointer_down: Some(on_pd),
1033                on_pointer_move: on_pm,
1034                on_pointer_up: Some(on_pu),
1035                on_pointer_enter: None,
1036                on_pointer_leave: None,
1037                z_index: z + 1000.0,
1038                on_text_change: None,
1039                on_text_submit: None,
1040                tf_state_key: None,
1041            });
1042        }
1043    }
1044
1045    fn push_scrollbar_h(
1046        scene: &mut Scene,
1047        hits: &mut Vec<HitRegion>,
1048        interactions: &Interactions,
1049        view_id: u64,
1050        vp: crate::Rect,
1051        content_w_px: f32,
1052        off_x_px: f32,
1053        z: f32,
1054        set_scroll_offset_xy: Option<Rc<dyn Fn(f32, f32)>>,
1055        keep_y: f32,
1056    ) {
1057        if content_w_px <= vp.w + 0.5 {
1058            return;
1059        }
1060        let thickness_px = dp_to_px(6.0);
1061        let margin_px = dp_to_px(2.0);
1062        let min_thumb_px = dp_to_px(24.0);
1063        let th = locals::theme();
1064
1065        let track_x = vp.x + margin_px;
1066        let track_y = vp.y + vp.h - margin_px - thickness_px;
1067        let track_w = (vp.w - 2.0 * margin_px).max(0.0);
1068
1069        let ratio = (vp.w / content_w_px).clamp(0.0, 1.0);
1070        let thumb_w = (track_w * ratio).clamp(min_thumb_px, track_w);
1071        let denom = (content_w_px - vp.w).max(1.0);
1072        let tpos = (off_x_px / denom).clamp(0.0, 1.0);
1073        let max_pos = (track_w - thumb_w).max(0.0);
1074        let thumb_x = track_x + tpos * max_pos;
1075
1076        scene.nodes.push(SceneNode::Rect {
1077            rect: crate::Rect {
1078                x: track_x,
1079                y: track_y,
1080                w: track_w,
1081                h: thickness_px,
1082            },
1083            color: th.scrollbar_track,
1084            radius: thickness_px * 0.5,
1085        });
1086        scene.nodes.push(SceneNode::Rect {
1087            rect: crate::Rect {
1088                x: thumb_x,
1089                y: track_y,
1090                w: thumb_w,
1091                h: thickness_px,
1092            },
1093            color: th.scrollbar_thumb,
1094            radius: thickness_px * 0.5,
1095        });
1096        if let Some(set_xy) = set_scroll_offset_xy {
1097            let hthumb_id: u64 = view_id ^ 0x8000_0012;
1098            let map_to_off_x = Rc::new(move |px_pos: f32| -> f32 {
1099                let denom = (content_w_px - vp.w).max(1.0);
1100                let max_pos = (track_w - thumb_w).max(0.0);
1101                let pos = ((px_pos - track_x) - thumb_w * 0.5).clamp(0.0, max_pos);
1102                let t = if max_pos > 0.0 { pos / max_pos } else { 0.0 };
1103                t * denom
1104            });
1105            let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1106                let set_xy = set_xy.clone();
1107                let map = map_to_off_x.clone();
1108                Rc::new(move |pe| set_xy(map(pe.position.x), keep_y))
1109            };
1110            let on_pm: Option<Rc<dyn Fn(repose_core::input::PointerEvent)>> =
1111                if interactions.pressed.contains(&hthumb_id) {
1112                    let set_xy = set_xy.clone();
1113                    let map = map_to_off_x.clone();
1114                    Some(Rc::new(move |pe| set_xy(map(pe.position.x), keep_y)))
1115                } else {
1116                    None
1117                };
1118            let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
1119            hits.push(HitRegion {
1120                id: hthumb_id,
1121                rect: crate::Rect {
1122                    x: thumb_x,
1123                    y: track_y,
1124                    w: thumb_w,
1125                    h: thickness_px,
1126                },
1127                on_click: None,
1128                on_scroll: None,
1129                focusable: false,
1130                on_pointer_down: Some(on_pd),
1131                on_pointer_move: on_pm,
1132                on_pointer_up: Some(on_pu),
1133                on_pointer_enter: None,
1134                on_pointer_leave: None,
1135                z_index: z + 1000.0,
1136                on_text_change: None,
1137                on_text_submit: None,
1138                tf_state_key: None,
1139            });
1140        }
1141    }
1142
1143    let mut scene = Scene {
1144        clear_color: locals::theme().background,
1145        nodes: vec![],
1146    };
1147    let mut hits: Vec<HitRegion> = vec![];
1148    let mut sems: Vec<SemNode> = vec![];
1149
1150    fn walk(
1151        v: &View,
1152        t: &TaffyTree<NodeCtx>,
1153        nodes: &HashMap<ViewId, taffy::NodeId>,
1154        scene: &mut Scene,
1155        hits: &mut Vec<HitRegion>,
1156        sems: &mut Vec<SemNode>,
1157        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
1158        interactions: &Interactions,
1159        focused: Option<u64>,
1160        parent_offset_px: (f32, f32),
1161        alpha_accum: f32,
1162        text_cache: &StdHashMap<taffy::NodeId, TextLayout>,
1163        font_px: &dyn Fn(f32) -> f32,
1164    ) {
1165        let local = layout_of(nodes[&v.id], t);
1166        let rect = add_offset(local, parent_offset_px);
1167
1168        // Convert padding from dp to px for content rect
1169        let content_rect = {
1170            if let Some(pv_dp) = v.modifier.padding_values {
1171                crate::Rect {
1172                    x: rect.x + dp_to_px(pv_dp.left),
1173                    y: rect.y + dp_to_px(pv_dp.top),
1174                    w: (rect.w - dp_to_px(pv_dp.left) - dp_to_px(pv_dp.right)).max(0.0),
1175                    h: (rect.h - dp_to_px(pv_dp.top) - dp_to_px(pv_dp.bottom)).max(0.0),
1176                }
1177            } else if let Some(p_dp) = v.modifier.padding {
1178                let p_px = dp_to_px(p_dp);
1179                crate::Rect {
1180                    x: rect.x + p_px,
1181                    y: rect.y + p_px,
1182                    w: (rect.w - 2.0 * p_px).max(0.0),
1183                    h: (rect.h - 2.0 * p_px).max(0.0),
1184                }
1185            } else {
1186                rect
1187            }
1188        };
1189
1190        let pad_dx = content_rect.x - rect.x;
1191        let pad_dy = content_rect.y - rect.y;
1192
1193        let base_px = (parent_offset_px.0 + local.x, parent_offset_px.1 + local.y);
1194
1195        let is_hovered = interactions.hover == Some(v.id);
1196        let is_pressed = interactions.pressed.contains(&v.id);
1197        let is_focused = focused == Some(v.id);
1198
1199        // Background/border
1200        if let Some(bg) = v.modifier.background {
1201            scene.nodes.push(SceneNode::Rect {
1202                rect,
1203                color: mul_alpha(bg, alpha_accum),
1204                radius: v.modifier.clip_rounded.map(dp_to_px).unwrap_or(0.0),
1205            });
1206        }
1207
1208        // Border
1209        if let Some(b) = &v.modifier.border {
1210            scene.nodes.push(SceneNode::Border {
1211                rect,
1212                color: mul_alpha(b.color, alpha_accum),
1213                width: dp_to_px(b.width),
1214                radius: dp_to_px(b.radius.max(v.modifier.clip_rounded.unwrap_or(0.0))),
1215            });
1216        }
1217
1218        // Transform and alpha
1219        let this_alpha = v.modifier.alpha.unwrap_or(1.0);
1220        let alpha_accum = (alpha_accum * this_alpha).clamp(0.0, 1.0);
1221
1222        if let Some(tf) = v.modifier.transform {
1223            scene.nodes.push(SceneNode::PushTransform { transform: tf });
1224        }
1225
1226        // Custom painter (Canvas)
1227        if let Some(p) = &v.modifier.painter {
1228            (p)(scene, rect);
1229        }
1230
1231        let has_pointer = v.modifier.on_pointer_down.is_some()
1232            || v.modifier.on_pointer_move.is_some()
1233            || v.modifier.on_pointer_up.is_some()
1234            || v.modifier.on_pointer_enter.is_some()
1235            || v.modifier.on_pointer_leave.is_some();
1236
1237        if has_pointer || v.modifier.click {
1238            hits.push(HitRegion {
1239                id: v.id,
1240                rect,
1241                on_click: None,  // unless ViewKind provides one
1242                on_scroll: None, // provided by ScrollV case
1243                focusable: false,
1244                on_pointer_down: v.modifier.on_pointer_down.clone(),
1245                on_pointer_move: v.modifier.on_pointer_move.clone(),
1246                on_pointer_up: v.modifier.on_pointer_up.clone(),
1247                on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1248                on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1249                z_index: v.modifier.z_index,
1250                on_text_change: None,
1251                on_text_submit: None,
1252                tf_state_key: None,
1253            });
1254        }
1255
1256        match &v.kind {
1257            ViewKind::Text {
1258                text,
1259                color,
1260                font_size: font_dp,
1261                soft_wrap,
1262                max_lines,
1263                overflow,
1264            } => {
1265                let nid = nodes[&v.id];
1266                let tl = text_cache.get(&nid);
1267
1268                let (size_px_val, line_h_px_val, mut lines): (f32, f32, Vec<String>) =
1269                    if let Some(tl) = tl {
1270                        (tl.size_px, tl.line_h_px, tl.lines.clone())
1271                    } else {
1272                        // Fallback
1273                        let sz_px = font_px(*font_dp);
1274                        (sz_px, sz_px * 1.3, vec![text.clone()])
1275                    };
1276                // Work within the content box
1277                let mut draw_box = content_rect;
1278                let max_w_px = draw_box.w.max(0.0);
1279                let max_h_px = draw_box.h.max(0.0);
1280
1281                // Vertical centering for single line within content box
1282                if lines.len() == 1 {
1283                    let dy_px = (draw_box.h - line_h_px_val) * 0.5;
1284                    if dy_px.is_finite() {
1285                        draw_box.y += dy_px.max(0.0);
1286                        draw_box.h = line_h_px_val;
1287                    }
1288                }
1289
1290                // For if height is constrained by rect.h and lines overflow visually,
1291                let max_visual_lines = if max_h_px > 0.5 {
1292                    (max_h_px / line_h_px_val).floor().max(1.0) as usize
1293                } else {
1294                    usize::MAX
1295                };
1296
1297                if lines.len() > max_visual_lines {
1298                    lines.truncate(max_visual_lines);
1299                    if *overflow == TextOverflow::Ellipsis && max_w_px > 0.5 {
1300                        // Ellipsize the last visible line
1301                        if let Some(last) = lines.last_mut() {
1302                            *last = repose_text::ellipsize_line(last, size_px_val, max_w_px);
1303                        }
1304                    }
1305                }
1306
1307                let approx_w_px = (text.len() as f32) * size_px_val * 0.6;
1308                let need_clip = match overflow {
1309                    TextOverflow::Visible | TextOverflow::Ellipsis => false,
1310                    TextOverflow::Clip => approx_w_px > max_w_px + 0.5,
1311                };
1312
1313                if need_clip {
1314                    scene.nodes.push(SceneNode::PushClip {
1315                        rect: draw_box,
1316                        radius: 0.0,
1317                    });
1318                }
1319
1320                for (i, ln) in lines.iter().enumerate() {
1321                    scene.nodes.push(SceneNode::Text {
1322                        rect: crate::Rect {
1323                            x: draw_box.x,
1324                            y: draw_box.y + i as f32 * line_h_px_val,
1325                            w: draw_box.w,
1326                            h: line_h_px_val,
1327                        },
1328                        text: ln.clone(),
1329                        color: mul_alpha(*color, alpha_accum),
1330                        size: size_px_val,
1331                    });
1332                }
1333
1334                if need_clip {
1335                    scene.nodes.push(SceneNode::PopClip);
1336                }
1337
1338                sems.push(SemNode {
1339                    id: v.id,
1340                    role: Role::Text,
1341                    label: Some(text.clone()),
1342                    rect,
1343                    focused: is_focused,
1344                    enabled: true,
1345                });
1346            }
1347
1348            ViewKind::Button { on_click } => {
1349                // Default background if none provided
1350                if v.modifier.background.is_none() {
1351                    let th = locals::theme();
1352                    let base = if is_pressed {
1353                        th.button_bg_pressed
1354                    } else if is_hovered {
1355                        th.button_bg_hover
1356                    } else {
1357                        th.button_bg
1358                    };
1359                    scene.nodes.push(SceneNode::Rect {
1360                        rect,
1361                        color: mul_alpha(base, alpha_accum),
1362                        radius: v.modifier.clip_rounded.map(dp_to_px).unwrap_or(6.0),
1363                    });
1364                }
1365
1366                if v.modifier.click || on_click.is_some() {
1367                    hits.push(HitRegion {
1368                        id: v.id,
1369                        rect,
1370                        on_click: on_click.clone(),
1371                        on_scroll: None,
1372                        focusable: true,
1373                        on_pointer_down: v.modifier.on_pointer_down.clone(),
1374                        on_pointer_move: v.modifier.on_pointer_move.clone(),
1375                        on_pointer_up: v.modifier.on_pointer_up.clone(),
1376                        on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1377                        on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1378                        z_index: v.modifier.z_index,
1379                        on_text_change: None,
1380                        on_text_submit: None,
1381                        tf_state_key: None,
1382                    });
1383                }
1384
1385                sems.push(SemNode {
1386                    id: v.id,
1387                    role: Role::Button,
1388                    label: None,
1389                    rect,
1390                    focused: is_focused,
1391                    enabled: true,
1392                });
1393
1394                if is_focused {
1395                    scene.nodes.push(SceneNode::Border {
1396                        rect,
1397                        color: mul_alpha(locals::theme().focus, alpha_accum),
1398                        width: dp_to_px(2.0),
1399                        radius: v
1400                            .modifier
1401                            .clip_rounded
1402                            .map(dp_to_px)
1403                            .unwrap_or(dp_to_px(6.0)),
1404                    });
1405                }
1406            }
1407            ViewKind::Image { handle, tint, fit } => {
1408                scene.nodes.push(SceneNode::Image {
1409                    rect,
1410                    handle: *handle,
1411                    tint: mul_alpha(*tint, alpha_accum),
1412                    fit: *fit,
1413                });
1414            }
1415
1416            ViewKind::TextField {
1417                state_key,
1418                hint,
1419                on_change,
1420                on_submit,
1421                ..
1422            } => {
1423                // Persistent key for platform-managed state
1424                let tf_key = if *state_key != 0 { *state_key } else { v.id };
1425
1426                hits.push(HitRegion {
1427                    id: v.id,
1428                    rect,
1429                    on_click: None,
1430                    on_scroll: None,
1431                    focusable: true,
1432                    on_pointer_down: None,
1433                    on_pointer_move: None,
1434                    on_pointer_up: None,
1435                    on_pointer_enter: None,
1436                    on_pointer_leave: None,
1437                    z_index: v.modifier.z_index,
1438                    on_text_change: on_change.clone(),
1439                    on_text_submit: on_submit.clone(),
1440                    tf_state_key: Some(tf_key),
1441                });
1442
1443                // Inner content rect (padding)
1444                let pad_x_px = dp_to_px(TF_PADDING_X_DP);
1445                let inner = repose_core::Rect {
1446                    x: rect.x + pad_x_px,
1447                    y: rect.y + dp_to_px(8.0),
1448                    w: rect.w - 2.0 * pad_x_px,
1449                    h: rect.h - dp_to_px(16.0),
1450                };
1451                scene.nodes.push(SceneNode::PushClip {
1452                    rect: inner,
1453                    radius: 0.0,
1454                });
1455                // TextField focus ring
1456                if is_focused {
1457                    scene.nodes.push(SceneNode::Border {
1458                        rect,
1459                        color: mul_alpha(locals::theme().focus, alpha_accum),
1460                        width: dp_to_px(2.0),
1461                        radius: v
1462                            .modifier
1463                            .clip_rounded
1464                            .map(dp_to_px)
1465                            .unwrap_or(dp_to_px(6.0)),
1466                    });
1467                }
1468
1469                if let Some(state_rc) = textfield_states
1470                    .get(&tf_key)
1471                    .or_else(|| textfield_states.get(&v.id))
1472                // fallback for older platforms
1473                {
1474                    state_rc.borrow_mut().set_inner_width(inner.w);
1475
1476                    let state = state_rc.borrow();
1477                    let text_val = &state.text;
1478                    let font_px_u32 = TF_FONT_DP as u32;
1479                    let m = measure_text(text_val, font_px_u32);
1480
1481                    // Selection highlight
1482                    if state.selection.start != state.selection.end {
1483                        let i0 = byte_to_char_index(&m, state.selection.start);
1484                        let i1 = byte_to_char_index(&m, state.selection.end);
1485                        let sx_px =
1486                            m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
1487                        let ex_px =
1488                            m.positions.get(i1).copied().unwrap_or(sx_px) - state.scroll_offset;
1489                        let sel_x_px = inner.x + sx_px.max(0.0);
1490                        let sel_w_px = (ex_px - sx_px).max(0.0);
1491                        scene.nodes.push(SceneNode::Rect {
1492                            rect: repose_core::Rect {
1493                                x: sel_x_px,
1494                                y: inner.y,
1495                                w: sel_w_px,
1496                                h: inner.h,
1497                            },
1498                            color: mul_alpha(Color::from_hex("#3B7BFF55"), alpha_accum),
1499                            radius: 0.0,
1500                        });
1501                    }
1502
1503                    // Composition underline
1504                    if let Some(range) = &state.composition {
1505                        if range.start < range.end && !text_val.is_empty() {
1506                            let i0 = byte_to_char_index(&m, range.start);
1507                            let i1 = byte_to_char_index(&m, range.end);
1508                            let sx_px =
1509                                m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
1510                            let ex_px =
1511                                m.positions.get(i1).copied().unwrap_or(sx_px) - state.scroll_offset;
1512                            let ux = inner.x + sx_px.max(0.0);
1513                            let uw = (ex_px - sx_px).max(0.0);
1514                            scene.nodes.push(SceneNode::Rect {
1515                                rect: repose_core::Rect {
1516                                    x: ux,
1517                                    y: inner.y + inner.h - dp_to_px(2.0),
1518                                    w: uw,
1519                                    h: dp_to_px(2.0),
1520                                },
1521                                color: mul_alpha(locals::theme().focus, alpha_accum),
1522                                radius: 0.0,
1523                            });
1524                        }
1525                    }
1526
1527                    // Text (offset by scroll)
1528                    let text_color = if text_val.is_empty() {
1529                        mul_alpha(Color::from_hex("#666666"), alpha_accum)
1530                    } else {
1531                        mul_alpha(locals::theme().on_surface, alpha_accum)
1532                    };
1533                    scene.nodes.push(SceneNode::Text {
1534                        rect: repose_core::Rect {
1535                            x: inner.x - state.scroll_offset,
1536                            y: inner.y,
1537                            w: inner.w,
1538                            h: inner.h,
1539                        },
1540                        text: if text_val.is_empty() {
1541                            hint.clone()
1542                        } else {
1543                            text_val.clone()
1544                        },
1545                        color: text_color,
1546                        size: font_px(TF_FONT_DP),
1547                    });
1548
1549                    // Caret (blink)
1550                    if state.selection.start == state.selection.end && state.caret_visible() {
1551                        let i = byte_to_char_index(&m, state.selection.end);
1552                        let cx_px =
1553                            m.positions.get(i).copied().unwrap_or(0.0) - state.scroll_offset;
1554                        let caret_x_px = inner.x + cx_px.max(0.0);
1555                        scene.nodes.push(SceneNode::Rect {
1556                            rect: repose_core::Rect {
1557                                x: caret_x_px,
1558                                y: inner.y,
1559                                w: dp_to_px(1.0),
1560                                h: inner.h,
1561                            },
1562                            color: mul_alpha(Color::WHITE, alpha_accum),
1563                            radius: 0.0,
1564                        });
1565                    }
1566                    // end inner clip
1567                    scene.nodes.push(SceneNode::PopClip);
1568
1569                    sems.push(SemNode {
1570                        id: v.id,
1571                        role: Role::TextField,
1572                        label: Some(text_val.clone()),
1573                        rect,
1574                        focused: is_focused,
1575                        enabled: true,
1576                    });
1577                } else {
1578                    // No state yet: show hint only
1579                    scene.nodes.push(SceneNode::Text {
1580                        rect: repose_core::Rect {
1581                            x: inner.x,
1582                            y: inner.y,
1583                            w: inner.w,
1584                            h: inner.h,
1585                        },
1586                        text: hint.clone(),
1587                        color: mul_alpha(Color::from_hex("#666666"), alpha_accum),
1588                        size: font_px(TF_FONT_DP),
1589                    });
1590                    scene.nodes.push(SceneNode::PopClip);
1591
1592                    sems.push(SemNode {
1593                        id: v.id,
1594                        role: Role::TextField,
1595                        label: Some(hint.clone()),
1596                        rect,
1597                        focused: is_focused,
1598                        enabled: true,
1599                    });
1600                }
1601            }
1602            ViewKind::ScrollV {
1603                on_scroll,
1604                set_viewport_height,
1605                set_content_height,
1606                get_scroll_offset,
1607                set_scroll_offset,
1608            } => {
1609                // Keep hit region as outer rect so scroll works even on padding
1610                hits.push(HitRegion {
1611                    id: v.id,
1612                    rect, // outer
1613                    on_click: None,
1614                    on_scroll: on_scroll.clone(),
1615                    focusable: false,
1616                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1617                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1618                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1619                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1620                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1621                    z_index: v.modifier.z_index,
1622                    on_text_change: None,
1623                    on_text_submit: None,
1624                    tf_state_key: None,
1625                });
1626
1627                // Use the inner content box (after padding) as the true viewport
1628                let vp = content_rect; // already computed above with padding converted to px
1629
1630                if let Some(set_vh) = set_viewport_height {
1631                    set_vh(vp.h.max(0.0));
1632                }
1633
1634                // True content height (use subtree extents per child)
1635                fn subtree_extents(node: taffy::NodeId, t: &TaffyTree<NodeCtx>) -> (f32, f32) {
1636                    let l = t.layout(node).unwrap();
1637                    let mut w = l.size.width;
1638                    let mut h = l.size.height;
1639                    if let Ok(children) = t.children(node) {
1640                        for &ch in children.iter() {
1641                            let cl = t.layout(ch).unwrap();
1642                            let (cw, chh) = subtree_extents(ch, t);
1643                            w = w.max(cl.location.x + cw);
1644                            h = h.max(cl.location.y + chh);
1645                        }
1646                    }
1647                    (w, h)
1648                }
1649                let mut content_h_px = 0.0f32;
1650                for c in &v.children {
1651                    let nid = nodes[&c.id];
1652                    let l = t.layout(nid).unwrap();
1653                    let (_cw, chh) = subtree_extents(nid, t);
1654                    content_h_px = content_h_px.max(l.location.y + chh);
1655                }
1656                if let Some(set_ch) = set_content_height {
1657                    set_ch(content_h_px);
1658                }
1659
1660                // Clip to the inner viewport
1661                scene.nodes.push(SceneNode::PushClip {
1662                    rect: vp,
1663                    radius: 0.0, // inner clip; keep simple (outer border already drawn if any)
1664                });
1665
1666                // Walk children
1667                let hit_start = hits.len();
1668                let scroll_offset_px = if let Some(get) = get_scroll_offset {
1669                    get()
1670                } else {
1671                    0.0
1672                };
1673                let child_offset_px = (base_px.0 + pad_dx, base_px.1 + pad_dy - scroll_offset_px);
1674                for c in &v.children {
1675                    walk(
1676                        c,
1677                        t,
1678                        nodes,
1679                        scene,
1680                        hits,
1681                        sems,
1682                        textfield_states,
1683                        interactions,
1684                        focused,
1685                        child_offset_px,
1686                        alpha_accum,
1687                        text_cache,
1688                        font_px,
1689                    );
1690                }
1691
1692                // Clip descendant hit regions to the viewport
1693                let mut i = hit_start;
1694                while i < hits.len() {
1695                    if let Some(r) = intersect(hits[i].rect, vp) {
1696                        hits[i].rect = r;
1697                        i += 1;
1698                    } else {
1699                        hits.remove(i);
1700                    }
1701                }
1702
1703                // Scrollbar overlay
1704                push_scrollbar_v(
1705                    scene,
1706                    hits,
1707                    interactions,
1708                    v.id,
1709                    vp,
1710                    content_h_px,
1711                    scroll_offset_px,
1712                    v.modifier.z_index,
1713                    set_scroll_offset.clone(),
1714                );
1715
1716                scene.nodes.push(SceneNode::PopClip);
1717                return;
1718            }
1719            ViewKind::ScrollXY {
1720                on_scroll,
1721                set_viewport_width,
1722                set_viewport_height,
1723                set_content_width,
1724                set_content_height,
1725                get_scroll_offset_xy,
1726                set_scroll_offset_xy,
1727            } => {
1728                hits.push(HitRegion {
1729                    id: v.id,
1730                    rect,
1731                    on_click: None,
1732                    on_scroll: on_scroll.clone(),
1733                    focusable: false,
1734                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1735                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1736                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1737                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1738                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1739                    z_index: v.modifier.z_index,
1740                    on_text_change: None,
1741                    on_text_submit: None,
1742                    tf_state_key: None,
1743                });
1744
1745                let vp = content_rect;
1746
1747                if let Some(set_w) = set_viewport_width {
1748                    set_w(vp.w.max(0.0));
1749                }
1750                if let Some(set_h) = set_viewport_height {
1751                    set_h(vp.h.max(0.0));
1752                }
1753
1754                fn subtree_extents(node: taffy::NodeId, t: &TaffyTree<NodeCtx>) -> (f32, f32) {
1755                    let l = t.layout(node).unwrap();
1756                    let mut w = l.size.width;
1757                    let mut h = l.size.height;
1758                    if let Ok(children) = t.children(node) {
1759                        for &ch in children.iter() {
1760                            let cl = t.layout(ch).unwrap();
1761                            let (cw, chh) = subtree_extents(ch, t);
1762                            w = w.max(cl.location.x + cw);
1763                            h = h.max(cl.location.y + chh);
1764                        }
1765                    }
1766                    (w, h)
1767                }
1768                let mut content_w_px = 0.0f32;
1769                let mut content_h_px = 0.0f32;
1770                for c in &v.children {
1771                    let nid = nodes[&c.id];
1772                    let l = t.layout(nid).unwrap();
1773                    let (cw, chh) = subtree_extents(nid, t);
1774                    content_w_px = content_w_px.max(l.location.x + cw);
1775                    content_h_px = content_h_px.max(l.location.y + chh);
1776                }
1777                if let Some(set_cw) = set_content_width {
1778                    set_cw(content_w_px);
1779                }
1780                if let Some(set_ch) = set_content_height {
1781                    set_ch(content_h_px);
1782                }
1783
1784                scene.nodes.push(SceneNode::PushClip {
1785                    rect: vp,
1786                    radius: 0.0,
1787                });
1788
1789                let hit_start = hits.len();
1790                let (ox_px, oy_px) = if let Some(get) = get_scroll_offset_xy {
1791                    get()
1792                } else {
1793                    (0.0, 0.0)
1794                };
1795                let child_offset_px = (base_px.0 + pad_dx - ox_px, base_px.1 + pad_dy - oy_px);
1796                for c in &v.children {
1797                    walk(
1798                        c,
1799                        t,
1800                        nodes,
1801                        scene,
1802                        hits,
1803                        sems,
1804                        textfield_states,
1805                        interactions,
1806                        focused,
1807                        child_offset_px,
1808                        alpha_accum,
1809                        text_cache,
1810                        font_px,
1811                    );
1812                }
1813                // Clip descendant hits to viewport
1814                let mut i = hit_start;
1815                while i < hits.len() {
1816                    if let Some(r) = intersect(hits[i].rect, vp) {
1817                        hits[i].rect = r;
1818                        i += 1;
1819                    } else {
1820                        hits.remove(i);
1821                    }
1822                }
1823
1824                let set_scroll_y: Option<Rc<dyn Fn(f32)>> =
1825                    set_scroll_offset_xy.clone().map(|set_xy| {
1826                        let ox = ox_px; // keep x, move only y
1827                        Rc::new(move |y| set_xy(ox, y)) as Rc<dyn Fn(f32)>
1828                    });
1829
1830                // Scrollbars against inner viewport
1831                push_scrollbar_v(
1832                    scene,
1833                    hits,
1834                    interactions,
1835                    v.id,
1836                    vp,
1837                    content_h_px,
1838                    oy_px,
1839                    v.modifier.z_index,
1840                    set_scroll_y,
1841                );
1842                push_scrollbar_h(
1843                    scene,
1844                    hits,
1845                    interactions,
1846                    v.id,
1847                    vp,
1848                    content_w_px,
1849                    ox_px,
1850                    v.modifier.z_index,
1851                    set_scroll_offset_xy.clone(),
1852                    oy_px,
1853                );
1854
1855                scene.nodes.push(SceneNode::PopClip);
1856                return;
1857            }
1858            ViewKind::Checkbox { checked, on_change } => {
1859                let theme = locals::theme();
1860                // Box at left (20x20 centered vertically)
1861                let box_size_px = dp_to_px(18.0);
1862                let bx = rect.x;
1863                let by = rect.y + (rect.h - box_size_px) * 0.5;
1864                // box bg/border
1865                scene.nodes.push(SceneNode::Rect {
1866                    rect: repose_core::Rect {
1867                        x: bx,
1868                        y: by,
1869                        w: box_size_px,
1870                        h: box_size_px,
1871                    },
1872                    color: if *checked {
1873                        mul_alpha(theme.primary, alpha_accum)
1874                    } else {
1875                        mul_alpha(theme.surface, alpha_accum)
1876                    },
1877                    radius: dp_to_px(3.0),
1878                });
1879                scene.nodes.push(SceneNode::Border {
1880                    rect: repose_core::Rect {
1881                        x: bx,
1882                        y: by,
1883                        w: box_size_px,
1884                        h: box_size_px,
1885                    },
1886                    color: mul_alpha(theme.outline, alpha_accum),
1887                    width: dp_to_px(1.0),
1888                    radius: dp_to_px(3.0),
1889                });
1890                // checkmark
1891                if *checked {
1892                    scene.nodes.push(SceneNode::Text {
1893                        rect: repose_core::Rect {
1894                            x: bx + dp_to_px(3.0),
1895                            y: rect.y + rect.h * 0.5 - font_px(16.0) * 0.6,
1896                            w: rect.w - (box_size_px + dp_to_px(8.0)),
1897                            h: font_px(16.0),
1898                        },
1899                        text: "✓".to_string(),
1900                        color: mul_alpha(theme.on_primary, alpha_accum),
1901                        size: font_px(16.0),
1902                    });
1903                }
1904                // Hit + semantics + focus ring
1905                let toggled = !*checked;
1906                let on_click = on_change.as_ref().map(|cb| {
1907                    let cb = cb.clone();
1908                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
1909                });
1910                hits.push(HitRegion {
1911                    id: v.id,
1912                    rect,
1913                    on_click,
1914                    on_scroll: None,
1915                    focusable: true,
1916                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1917                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1918                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1919                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1920                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1921                    z_index: v.modifier.z_index,
1922                    on_text_change: None,
1923                    on_text_submit: None,
1924                    tf_state_key: None,
1925                });
1926                sems.push(SemNode {
1927                    id: v.id,
1928                    role: Role::Checkbox,
1929                    label: None,
1930                    rect,
1931                    focused: is_focused,
1932                    enabled: true,
1933                });
1934                if is_focused {
1935                    scene.nodes.push(SceneNode::Border {
1936                        rect,
1937                        color: mul_alpha(locals::theme().focus, alpha_accum),
1938                        width: dp_to_px(2.0),
1939                        radius: v
1940                            .modifier
1941                            .clip_rounded
1942                            .map(dp_to_px)
1943                            .unwrap_or(dp_to_px(6.0)),
1944                    });
1945                }
1946            }
1947
1948            ViewKind::RadioButton {
1949                selected,
1950
1951                on_select,
1952            } => {
1953                let theme = locals::theme();
1954                let d_px = dp_to_px(18.0);
1955                let cx = rect.x;
1956                let cy = rect.y + (rect.h - d_px) * 0.5;
1957
1958                // outer circle (rounded rect as circle)
1959                scene.nodes.push(SceneNode::Border {
1960                    rect: repose_core::Rect {
1961                        x: cx,
1962                        y: cy,
1963                        w: d_px,
1964                        h: d_px,
1965                    },
1966                    color: mul_alpha(theme.outline, alpha_accum),
1967                    width: dp_to_px(1.5),
1968                    radius: d_px * 0.5,
1969                });
1970                // inner dot if selected
1971                if *selected {
1972                    scene.nodes.push(SceneNode::Rect {
1973                        rect: repose_core::Rect {
1974                            x: cx + dp_to_px(4.0),
1975                            y: cy + dp_to_px(4.0),
1976                            w: d_px - dp_to_px(8.0),
1977                            h: d_px - dp_to_px(8.0),
1978                        },
1979                        color: mul_alpha(theme.primary, alpha_accum),
1980                        radius: (d_px - dp_to_px(8.0)) * 0.5,
1981                    });
1982                }
1983
1984                hits.push(HitRegion {
1985                    id: v.id,
1986                    rect,
1987                    on_click: on_select.clone(),
1988                    on_scroll: None,
1989                    focusable: true,
1990                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1991                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1992                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1993                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1994                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1995                    z_index: v.modifier.z_index,
1996                    on_text_change: None,
1997                    on_text_submit: None,
1998                    tf_state_key: None,
1999                });
2000                sems.push(SemNode {
2001                    id: v.id,
2002                    role: Role::RadioButton,
2003                    label: None,
2004                    rect,
2005                    focused: is_focused,
2006                    enabled: true,
2007                });
2008                if is_focused {
2009                    scene.nodes.push(SceneNode::Border {
2010                        rect,
2011                        color: mul_alpha(locals::theme().focus, alpha_accum),
2012                        width: dp_to_px(2.0),
2013                        radius: v
2014                            .modifier
2015                            .clip_rounded
2016                            .map(dp_to_px)
2017                            .unwrap_or(dp_to_px(6.0)),
2018                    });
2019                }
2020            }
2021
2022            ViewKind::Switch { checked, on_change } => {
2023                let theme = locals::theme();
2024                // track 46x26, knob 22x22
2025                let track_w_px = dp_to_px(46.0);
2026                let track_h_px = dp_to_px(26.0);
2027                let tx = rect.x;
2028                let ty = rect.y + (rect.h - track_h_px) * 0.5;
2029                let knob_px = dp_to_px(22.0);
2030                let on_col = theme.primary;
2031                let off_col = Color::from_hex("#333333");
2032
2033                // track
2034                scene.nodes.push(SceneNode::Rect {
2035                    rect: repose_core::Rect {
2036                        x: tx,
2037                        y: ty,
2038                        w: track_w_px,
2039                        h: track_h_px,
2040                    },
2041                    color: if *checked {
2042                        mul_alpha(on_col, alpha_accum)
2043                    } else {
2044                        mul_alpha(off_col, alpha_accum)
2045                    },
2046                    radius: track_h_px * 0.5,
2047                });
2048                // knob position
2049                let kx = if *checked {
2050                    tx + track_w_px - knob_px - dp_to_px(2.0)
2051                } else {
2052                    tx + dp_to_px(2.0)
2053                };
2054                let ky = ty + (track_h_px - knob_px) * 0.5;
2055                scene.nodes.push(SceneNode::Rect {
2056                    rect: repose_core::Rect {
2057                        x: kx,
2058                        y: ky,
2059                        w: knob_px,
2060                        h: knob_px,
2061                    },
2062                    color: mul_alpha(Color::from_hex("#EEEEEE"), alpha_accum),
2063                    radius: knob_px * 0.5,
2064                });
2065                scene.nodes.push(SceneNode::Border {
2066                    rect: repose_core::Rect {
2067                        x: kx,
2068                        y: ky,
2069                        w: knob_px,
2070                        h: knob_px,
2071                    },
2072                    color: mul_alpha(theme.outline, alpha_accum),
2073                    width: dp_to_px(1.0),
2074                    radius: knob_px * 0.5,
2075                });
2076
2077                let toggled = !*checked;
2078                let on_click = on_change.as_ref().map(|cb| {
2079                    let cb = cb.clone();
2080                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
2081                });
2082                hits.push(HitRegion {
2083                    id: v.id,
2084                    rect,
2085                    on_click,
2086                    on_scroll: None,
2087                    focusable: true,
2088                    on_pointer_down: v.modifier.on_pointer_down.clone(),
2089                    on_pointer_move: v.modifier.on_pointer_move.clone(),
2090                    on_pointer_up: v.modifier.on_pointer_up.clone(),
2091                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2092                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2093                    z_index: v.modifier.z_index,
2094                    on_text_change: None,
2095                    on_text_submit: None,
2096                    tf_state_key: None,
2097                });
2098                sems.push(SemNode {
2099                    id: v.id,
2100                    role: Role::Switch,
2101                    label: None,
2102                    rect,
2103                    focused: is_focused,
2104                    enabled: true,
2105                });
2106                if is_focused {
2107                    scene.nodes.push(SceneNode::Border {
2108                        rect,
2109                        color: mul_alpha(locals::theme().focus, alpha_accum),
2110                        width: dp_to_px(2.0),
2111                        radius: v
2112                            .modifier
2113                            .clip_rounded
2114                            .map(dp_to_px)
2115                            .unwrap_or(dp_to_px(6.0)),
2116                    });
2117                }
2118            }
2119            ViewKind::Slider {
2120                value,
2121                min,
2122                max,
2123                step,
2124                on_change,
2125            } => {
2126                let theme = locals::theme();
2127                // Layout: [track | label]
2128                let track_h_px = dp_to_px(4.0);
2129                let knob_d_px = dp_to_px(20.0);
2130                let gap_px = dp_to_px(8.0);
2131                let label_x = rect.x + rect.w * 0.6; // simple split: 60% track, 40% label
2132                let track_x = rect.x;
2133                let track_w_px = (label_x - track_x).max(dp_to_px(60.0));
2134                let cy = rect.y + rect.h * 0.5;
2135
2136                // Track
2137                scene.nodes.push(SceneNode::Rect {
2138                    rect: repose_core::Rect {
2139                        x: track_x,
2140                        y: cy - track_h_px * 0.5,
2141                        w: track_w_px,
2142                        h: track_h_px,
2143                    },
2144                    color: mul_alpha(Color::from_hex("#333333"), alpha_accum),
2145                    radius: track_h_px * 0.5,
2146                });
2147
2148                // Knob position
2149                let t = clamp01(norm(*value, *min, *max));
2150                let kx = track_x + t * track_w_px;
2151                scene.nodes.push(SceneNode::Rect {
2152                    rect: repose_core::Rect {
2153                        x: kx - knob_d_px * 0.5,
2154                        y: cy - knob_d_px * 0.5,
2155                        w: knob_d_px,
2156                        h: knob_d_px,
2157                    },
2158                    color: mul_alpha(theme.surface, alpha_accum),
2159                    radius: knob_d_px * 0.5,
2160                });
2161                scene.nodes.push(SceneNode::Border {
2162                    rect: repose_core::Rect {
2163                        x: kx - knob_d_px * 0.5,
2164                        y: cy - knob_d_px * 0.5,
2165                        w: knob_d_px,
2166                        h: knob_d_px,
2167                    },
2168                    color: mul_alpha(theme.outline, alpha_accum),
2169                    width: dp_to_px(1.0),
2170                    radius: knob_d_px * 0.5,
2171                });
2172
2173                // Interactions
2174                let on_change_cb: Option<Rc<dyn Fn(f32)>> = on_change.as_ref().cloned();
2175                let minv = *min;
2176                let maxv = *max;
2177                let stepv = *step;
2178
2179                // per-hit-region current value (wheel deltas accumulate within a frame)
2180                let current = Rc::new(RefCell::new(*value));
2181
2182                // pointer mapping closure (in global coords, px)
2183                let update_at = {
2184                    let on_change_cb = on_change_cb.clone();
2185                    let current = current.clone();
2186                    Rc::new(move |px_pos: f32| {
2187                        let tt = clamp01((px_pos - track_x) / track_w_px);
2188                        let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
2189                        *current.borrow_mut() = v;
2190                        if let Some(cb) = &on_change_cb {
2191                            cb(v);
2192                        }
2193                    })
2194                };
2195
2196                // on_pointer_down: update once at press
2197                let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2198                    let f = update_at.clone();
2199                    Rc::new(move |pe| {
2200                        f(pe.position.x);
2201                    })
2202                };
2203
2204                // on_pointer_move: platform only delivers here while captured
2205                let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2206                    let f = update_at.clone();
2207                    Rc::new(move |pe| {
2208                        f(pe.position.x);
2209                    })
2210                };
2211
2212                // on_pointer_up: no-op
2213                let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
2214
2215                // Mouse wheel nudge: accumulate via 'current'
2216                let on_scroll = {
2217                    let on_change_cb = on_change_cb.clone();
2218                    let current = current.clone();
2219                    Rc::new(move |d: Vec2| -> Vec2 {
2220                        let base = *current.borrow();
2221                        let delta = stepv.unwrap_or((maxv - minv) * 0.01);
2222                        // wheel-up (negative y) increases
2223                        let dir = if d.y.is_sign_negative() { 1.0 } else { -1.0 };
2224                        let new_v = snap_step(base + dir * delta, stepv, minv, maxv);
2225                        *current.borrow_mut() = new_v;
2226                        if let Some(cb) = &on_change_cb {
2227                            cb(new_v);
2228                        }
2229                        Vec2 { x: d.x, y: 0.0 } // we consumed all y, pass x through
2230                    })
2231                };
2232
2233                // Register move handler only while pressed so hover doesn't change value
2234                hits.push(HitRegion {
2235                    id: v.id,
2236                    rect,
2237                    on_click: None,
2238                    on_scroll: Some(on_scroll),
2239                    focusable: true,
2240                    on_pointer_down: Some(on_pd),
2241                    on_pointer_move: if is_pressed { Some(on_pm) } else { None },
2242                    on_pointer_up: Some(on_pu),
2243                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2244                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2245                    z_index: v.modifier.z_index,
2246                    on_text_change: None,
2247                    on_text_submit: None,
2248                    tf_state_key: None,
2249                });
2250
2251                sems.push(SemNode {
2252                    id: v.id,
2253                    role: Role::Slider,
2254                    label: None,
2255                    rect,
2256                    focused: is_focused,
2257                    enabled: true,
2258                });
2259                if is_focused {
2260                    scene.nodes.push(SceneNode::Border {
2261                        rect,
2262                        color: mul_alpha(locals::theme().focus, alpha_accum),
2263                        width: dp_to_px(2.0),
2264                        radius: v
2265                            .modifier
2266                            .clip_rounded
2267                            .map(dp_to_px)
2268                            .unwrap_or(dp_to_px(6.0)),
2269                    });
2270                }
2271            }
2272            ViewKind::RangeSlider {
2273                start,
2274                end,
2275                min,
2276                max,
2277                step,
2278                on_change,
2279            } => {
2280                let theme = locals::theme();
2281                let track_h_px = dp_to_px(4.0);
2282                let knob_d_px = dp_to_px(20.0);
2283                let gap_px = dp_to_px(8.0);
2284                let label_x = rect.x + rect.w * 0.6;
2285                let track_x = rect.x;
2286                let track_w_px = (label_x - track_x).max(dp_to_px(80.0));
2287                let cy = rect.y + rect.h * 0.5;
2288
2289                // Track
2290                scene.nodes.push(SceneNode::Rect {
2291                    rect: repose_core::Rect {
2292                        x: track_x,
2293                        y: cy - track_h_px * 0.5,
2294                        w: track_w_px,
2295                        h: track_h_px,
2296                    },
2297                    color: mul_alpha(Color::from_hex("#333333"), alpha_accum),
2298                    radius: track_h_px * 0.5,
2299                });
2300
2301                // Positions
2302                let t0 = clamp01(norm(*start, *min, *max));
2303                let t1 = clamp01(norm(*end, *min, *max));
2304                let k0x = track_x + t0 * track_w_px;
2305                let k1x = track_x + t1 * track_w_px;
2306
2307                // Range fill
2308                scene.nodes.push(SceneNode::Rect {
2309                    rect: repose_core::Rect {
2310                        x: k0x.min(k1x),
2311                        y: cy - track_h_px * 0.5,
2312                        w: (k1x - k0x).abs(),
2313                        h: track_h_px,
2314                    },
2315                    color: mul_alpha(theme.primary, alpha_accum),
2316                    radius: track_h_px * 0.5,
2317                });
2318
2319                // Knobs
2320                for &kx in &[k0x, k1x] {
2321                    scene.nodes.push(SceneNode::Rect {
2322                        rect: repose_core::Rect {
2323                            x: kx - knob_d_px * 0.5,
2324                            y: cy - knob_d_px * 0.5,
2325                            w: knob_d_px,
2326                            h: knob_d_px,
2327                        },
2328                        color: mul_alpha(theme.surface, alpha_accum),
2329                        radius: knob_d_px * 0.5,
2330                    });
2331                    scene.nodes.push(SceneNode::Border {
2332                        rect: repose_core::Rect {
2333                            x: kx - knob_d_px * 0.5,
2334                            y: cy - knob_d_px * 0.5,
2335                            w: knob_d_px,
2336                            h: knob_d_px,
2337                        },
2338                        color: mul_alpha(theme.outline, alpha_accum),
2339                        width: dp_to_px(1.0),
2340                        radius: knob_d_px * 0.5,
2341                    });
2342                }
2343
2344                // Interaction
2345                let on_change_cb = on_change.as_ref().cloned();
2346                let minv = *min;
2347                let maxv = *max;
2348                let stepv = *step;
2349                let start_val = *start;
2350                let end_val = *end;
2351
2352                // which thumb is active during drag: Some(0) or Some(1)
2353                let active = Rc::new(RefCell::new(None::<u8>));
2354
2355                // update for current active thumb; does nothing if None
2356                let update = {
2357                    let active = active.clone();
2358                    let on_change_cb = on_change_cb.clone();
2359                    Rc::new(move |px_pos: f32| {
2360                        if let Some(thumb) = *active.borrow() {
2361                            let tt = clamp01((px_pos - track_x) / track_w_px);
2362                            let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
2363                            match thumb {
2364                                0 => {
2365                                    let new_start = v.min(end_val).min(maxv).max(minv);
2366                                    if let Some(cb) = &on_change_cb {
2367                                        cb(new_start, end_val);
2368                                    }
2369                                }
2370                                _ => {
2371                                    let new_end = v.max(start_val).max(minv).min(maxv);
2372                                    if let Some(cb) = &on_change_cb {
2373                                        cb(start_val, new_end);
2374                                    }
2375                                }
2376                            }
2377                        }
2378                    })
2379                };
2380
2381                // on_pointer_down: choose nearest thumb and update once
2382                let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2383                    let active = active.clone();
2384                    let update = update.clone();
2385                    // snapshot thumb positions for hit decision
2386                    let k0x0 = k0x;
2387                    let k1x0 = k1x;
2388                    Rc::new(move |pe| {
2389                        let px_pos = pe.position.x;
2390                        let d0 = (px_pos - k0x0).abs();
2391                        let d1 = (px_pos - k1x0).abs();
2392                        *active.borrow_mut() = Some(if d0 <= d1 { 0 } else { 1 });
2393                        update(px_pos);
2394                    })
2395                };
2396
2397                // on_pointer_move: update only while a thumb is active
2398                let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2399                    let active = active.clone();
2400                    let update = update.clone();
2401                    Rc::new(move |pe| {
2402                        if active.borrow().is_some() {
2403                            update(pe.position.x);
2404                        }
2405                    })
2406                };
2407
2408                // on_pointer_up: clear active thumb
2409                let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
2410                    let active = active.clone();
2411                    Rc::new(move |_pe| {
2412                        *active.borrow_mut() = None;
2413                    })
2414                };
2415
2416                hits.push(HitRegion {
2417                    id: v.id,
2418                    rect,
2419                    on_click: None,
2420                    on_scroll: None,
2421                    focusable: true,
2422                    on_pointer_down: Some(on_pd),
2423                    on_pointer_move: Some(on_pm),
2424                    on_pointer_up: Some(on_pu),
2425                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
2426                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
2427                    z_index: v.modifier.z_index,
2428                    on_text_change: None,
2429                    on_text_submit: None,
2430                    tf_state_key: None,
2431                });
2432                sems.push(SemNode {
2433                    id: v.id,
2434                    role: Role::Slider,
2435                    label: None,
2436                    rect,
2437                    focused: is_focused,
2438                    enabled: true,
2439                });
2440                if is_focused {
2441                    scene.nodes.push(SceneNode::Border {
2442                        rect,
2443                        color: mul_alpha(locals::theme().focus, alpha_accum),
2444                        width: dp_to_px(2.0),
2445                        radius: v
2446                            .modifier
2447                            .clip_rounded
2448                            .map(dp_to_px)
2449                            .unwrap_or(dp_to_px(6.0)),
2450                    });
2451                }
2452            }
2453            ViewKind::ProgressBar {
2454                value,
2455                min,
2456                max,
2457                circular: _,
2458            } => {
2459                let theme = locals::theme();
2460                let track_h_px = dp_to_px(6.0);
2461                let gap_px = dp_to_px(8.0);
2462                let label_w_split_px = rect.w * 0.6;
2463                let track_x = rect.x;
2464                let track_w_px = (label_w_split_px - track_x).max(dp_to_px(60.0));
2465                let cy = rect.y + rect.h * 0.5;
2466
2467                scene.nodes.push(SceneNode::Rect {
2468                    rect: repose_core::Rect {
2469                        x: track_x,
2470                        y: cy - track_h_px * 0.5,
2471                        w: track_w_px,
2472                        h: track_h_px,
2473                    },
2474                    color: mul_alpha(Color::from_hex("#333333"), alpha_accum),
2475                    radius: track_h_px * 0.5,
2476                });
2477
2478                let t = clamp01(norm(*value, *min, *max));
2479                scene.nodes.push(SceneNode::Rect {
2480                    rect: repose_core::Rect {
2481                        x: track_x,
2482                        y: cy - track_h_px * 0.5,
2483                        w: track_w_px * t,
2484                        h: track_h_px,
2485                    },
2486                    color: mul_alpha(theme.primary, alpha_accum),
2487                    radius: track_h_px * 0.5,
2488                });
2489
2490                scene.nodes.push(SceneNode::Text {
2491                    rect: repose_core::Rect {
2492                        x: rect.x + label_w_split_px + gap_px,
2493                        y: rect.y + rect.h * 0.5 - font_px(16.0) * 0.6,
2494                        w: rect.w - (label_w_split_px + gap_px),
2495                        h: font_px(16.0),
2496                    },
2497                    text: format!("{:.0}%", t * 100.0),
2498                    color: mul_alpha(theme.on_surface, alpha_accum),
2499                    size: font_px(16.0),
2500                });
2501
2502                sems.push(SemNode {
2503                    id: v.id,
2504                    role: Role::ProgressBar,
2505                    label: None,
2506                    rect,
2507                    focused: is_focused,
2508                    enabled: true,
2509                });
2510            }
2511
2512            _ => {}
2513        }
2514
2515        // Recurse (no extra clip by default)
2516        for c in &v.children {
2517            walk(
2518                c,
2519                t,
2520                nodes,
2521                scene,
2522                hits,
2523                sems,
2524                textfield_states,
2525                interactions,
2526                focused,
2527                base_px,
2528                alpha_accum,
2529                text_cache,
2530                font_px,
2531            );
2532        }
2533
2534        if v.modifier.transform.is_some() {
2535            scene.nodes.push(SceneNode::PopTransform);
2536        }
2537    }
2538
2539    let font_px = |dp_font: f32| dp_to_px(dp_font) * locals::text_scale().0;
2540
2541    // Start with zero offset
2542    walk(
2543        &root,
2544        &taffy,
2545        &nodes_map,
2546        &mut scene,
2547        &mut hits,
2548        &mut sems,
2549        textfield_states,
2550        interactions,
2551        focused,
2552        (0.0, 0.0),
2553        1.0,
2554        &text_cache,
2555        &font_px,
2556    );
2557
2558    // Ensure visual order: low z_index first. Topmost will be found by iter().rev().
2559    hits.sort_by(|a, b| a.z_index.partial_cmp(&b.z_index).unwrap_or(Ordering::Equal));
2560
2561    (scene, hits, sems)
2562}
2563
2564/// Method styling
2565pub trait TextStyle {
2566    fn color(self, c: Color) -> View;
2567    fn size(self, px: f32) -> View;
2568    fn max_lines(self, n: usize) -> View;
2569    fn single_line(self) -> View;
2570    fn overflow_ellipsize(self) -> View;
2571    fn overflow_clip(self) -> View;
2572    fn overflow_visible(self) -> View;
2573}
2574impl TextStyle for View {
2575    fn color(mut self, c: Color) -> View {
2576        if let ViewKind::Text {
2577            color: text_color, ..
2578        } = &mut self.kind
2579        {
2580            *text_color = c;
2581        }
2582        self
2583    }
2584    fn size(mut self, dp_font: f32) -> View {
2585        if let ViewKind::Text {
2586            font_size: text_size_dp,
2587            ..
2588        } = &mut self.kind
2589        {
2590            *text_size_dp = dp_font;
2591        }
2592        self
2593    }
2594    fn max_lines(mut self, n: usize) -> View {
2595        if let ViewKind::Text {
2596            max_lines,
2597            soft_wrap,
2598            ..
2599        } = &mut self.kind
2600        {
2601            *max_lines = Some(n);
2602            *soft_wrap = true;
2603        }
2604        self
2605    }
2606    fn single_line(mut self) -> View {
2607        if let ViewKind::Text {
2608            soft_wrap,
2609            max_lines,
2610            ..
2611        } = &mut self.kind
2612        {
2613            *soft_wrap = false;
2614            *max_lines = Some(1);
2615        }
2616        self
2617    }
2618    fn overflow_ellipsize(mut self) -> View {
2619        if let ViewKind::Text { overflow, .. } = &mut self.kind {
2620            *overflow = TextOverflow::Ellipsis;
2621        }
2622        self
2623    }
2624    fn overflow_clip(mut self) -> View {
2625        if let ViewKind::Text { overflow, .. } = &mut self.kind {
2626            *overflow = TextOverflow::Clip;
2627        }
2628        self
2629    }
2630    fn overflow_visible(mut self) -> View {
2631        if let ViewKind::Text { overflow, .. } = &mut self.kind {
2632            *overflow = TextOverflow::Visible;
2633        }
2634        self
2635    }
2636}