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