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