repose_ui/
lib.rs

1//! Widgets, layout (Taffy), painting into a platform-agnostic Scene, and text fields.
2//!
3//! repose-render-wgpu is responsible for GPU interaction
4
5#![allow(non_snake_case)]
6pub mod anim;
7pub mod lazy;
8
9use std::collections::{HashMap, HashSet};
10use std::rc::Rc;
11use std::{cell::RefCell, cmp::Ordering};
12
13use repose_core::*;
14use taffy::Overflow;
15use taffy::style::{AlignItems, Dimension, Display, FlexDirection, JustifyContent, Style};
16
17use taffy::prelude::{Position, Size, auto, length, percent};
18
19pub mod textfield;
20pub use textfield::{TextField, TextFieldState};
21
22use crate::textfield::{TF_FONT_PX, TF_PADDING_X, byte_to_char_index, measure_text, positions_for};
23use repose_core::locals;
24
25#[derive(Default)]
26pub struct Interactions {
27    pub hover: Option<u64>,
28    pub pressed: HashSet<u64>,
29}
30
31pub fn Surface(modifier: Modifier, child: View) -> View {
32    let mut v = View::new(0, ViewKind::Surface).modifier(modifier);
33    v.children = vec![child];
34    v
35}
36
37pub fn Box(modifier: Modifier) -> View {
38    View::new(0, ViewKind::Box).modifier(modifier)
39}
40
41pub fn Row(modifier: Modifier) -> View {
42    View::new(0, ViewKind::Row).modifier(modifier)
43}
44
45pub fn Column(modifier: Modifier) -> View {
46    View::new(0, ViewKind::Column).modifier(modifier)
47}
48
49pub fn Stack(modifier: Modifier) -> View {
50    View::new(0, ViewKind::Stack).modifier(modifier)
51}
52
53pub fn Scroll(modifier: Modifier) -> View {
54    View::new(
55        0,
56        ViewKind::ScrollV {
57            on_scroll: None,
58            set_viewport_height: None,
59            get_scroll_offset: None,
60        },
61    )
62    .modifier(modifier)
63}
64
65pub fn Text(text: impl Into<String>) -> View {
66    View::new(
67        0,
68        ViewKind::Text {
69            text: text.into(),
70            color: Color::WHITE,
71            font_size: 16.0,
72        },
73    )
74}
75
76pub fn Spacer() -> View {
77    Box(Modifier::new().flex_grow(1.0))
78}
79
80pub fn Grid(columns: usize, modifier: Modifier, children: Vec<View>) -> View {
81    Column(modifier.grid(columns, 0.0, 0.0)).with_children(children)
82}
83
84#[allow(non_snake_case)]
85pub fn TextColor(mut v: View, color: Color) -> View {
86    if let ViewKind::Text {
87        color: text_color, ..
88    } = &mut v.kind
89    {
90        *text_color = color;
91    }
92    v
93}
94
95#[allow(non_snake_case)]
96pub fn TextSize(mut v: View, size: f32) -> View {
97    if let ViewKind::Text {
98        font_size: text_size,
99        ..
100    } = &mut v.kind
101    {
102        *text_size = size;
103    }
104    v
105}
106
107pub fn Button(text: impl Into<String>, on_click: impl Fn() + 'static) -> View {
108    View::new(
109        0,
110        ViewKind::Button {
111            text: text.into(),
112            on_click: Some(Rc::new(on_click)),
113        },
114    )
115    .semantics(Semantics {
116        role: Role::Button,
117        label: None,
118        focused: false,
119        enabled: true,
120    })
121}
122
123pub fn Checkbox(
124    checked: bool,
125    label: impl Into<String>,
126    on_change: impl Fn(bool) + 'static,
127) -> View {
128    View::new(
129        0,
130        ViewKind::Checkbox {
131            checked,
132            label: label.into(),
133            on_change: Some(Rc::new(on_change)),
134        },
135    )
136    .semantics(Semantics {
137        role: Role::Checkbox,
138        label: None,
139        focused: false,
140        enabled: true,
141    })
142}
143
144pub fn RadioButton(
145    selected: bool,
146    label: impl Into<String>,
147    on_select: impl Fn() + 'static,
148) -> View {
149    View::new(
150        0,
151        ViewKind::RadioButton {
152            selected,
153            label: label.into(),
154            on_select: Some(Rc::new(on_select)),
155        },
156    )
157    .semantics(Semantics {
158        role: Role::RadioButton,
159        label: None,
160        focused: false,
161        enabled: true,
162    })
163}
164
165pub fn Switch(checked: bool, label: impl Into<String>, on_change: impl Fn(bool) + 'static) -> View {
166    View::new(
167        0,
168        ViewKind::Switch {
169            checked,
170            label: label.into(),
171            on_change: Some(Rc::new(on_change)),
172        },
173    )
174    .semantics(Semantics {
175        role: Role::Switch,
176        label: None,
177        focused: false,
178        enabled: true,
179    })
180}
181
182pub fn Slider(
183    value: f32,
184    range: (f32, f32),
185    step: Option<f32>,
186    label: impl Into<String>,
187    on_change: impl Fn(f32) + 'static,
188) -> View {
189    View::new(
190        0,
191        ViewKind::Slider {
192            value,
193            min: range.0,
194            max: range.1,
195            step,
196            label: label.into(),
197            on_change: Some(Rc::new(on_change)),
198        },
199    )
200    .semantics(Semantics {
201        role: Role::Slider,
202        label: None,
203        focused: false,
204        enabled: true,
205    })
206}
207
208pub fn RangeSlider(
209    start: f32,
210    end: f32,
211    range: (f32, f32),
212    step: Option<f32>,
213    label: impl Into<String>,
214    on_change: impl Fn(f32, f32) + 'static,
215) -> View {
216    View::new(
217        0,
218        ViewKind::RangeSlider {
219            start,
220            end,
221            min: range.0,
222            max: range.1,
223            step,
224            label: label.into(),
225            on_change: Some(Rc::new(on_change)),
226        },
227    )
228    .semantics(Semantics {
229        role: Role::Slider,
230        label: None,
231        focused: false,
232        enabled: true,
233    })
234}
235
236pub fn ProgressBar(value: f32, range: (f32, f32), label: impl Into<String>) -> View {
237    View::new(
238        0,
239        ViewKind::ProgressBar {
240            value,
241            min: range.0,
242            max: range.1,
243            label: label.into(),
244            circular: false,
245        },
246    )
247    .semantics(Semantics {
248        role: Role::ProgressBar,
249        label: None,
250        focused: false,
251        enabled: true,
252    })
253}
254
255fn flex_dir_for(kind: &ViewKind) -> Option<FlexDirection> {
256    match kind {
257        ViewKind::Row => {
258            if repose_core::locals::text_direction() == repose_core::locals::TextDirection::Rtl {
259                Some(FlexDirection::RowReverse)
260            } else {
261                Some(FlexDirection::Row)
262            }
263        }
264        ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. } => {
265            Some(FlexDirection::Column)
266        }
267        _ => None,
268    }
269}
270
271/// Extension trait for child building
272pub trait ViewExt: Sized {
273    fn child(self, children: impl IntoChildren) -> Self;
274}
275
276impl ViewExt for View {
277    fn child(self, children: impl IntoChildren) -> Self {
278        self.with_children(children.into_children())
279    }
280}
281
282pub trait IntoChildren {
283    fn into_children(self) -> Vec<View>;
284}
285
286impl IntoChildren for View {
287    fn into_children(self) -> Vec<View> {
288        vec![self]
289    }
290}
291
292impl IntoChildren for Vec<View> {
293    fn into_children(self) -> Vec<View> {
294        self
295    }
296}
297
298impl<const N: usize> IntoChildren for [View; N] {
299    fn into_children(self) -> Vec<View> {
300        self.into()
301    }
302}
303
304// Tuple implementations
305macro_rules! impl_into_children_tuple {
306    ($($idx:tt $t:ident),+) => {
307        impl<$($t: IntoChildren),+> IntoChildren for ($($t,)+) {
308            fn into_children(self) -> Vec<View> {
309                let mut v = Vec::new();
310                $(v.extend(self.$idx.into_children());)+
311                v
312            }
313        }
314    };
315}
316
317impl_into_children_tuple!(0 A, 1 B);
318impl_into_children_tuple!(0 A, 1 B, 2 C);
319impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D);
320impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E);
321impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F);
322impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G);
323impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H);
324
325/// Layout and paint with TextField state injection (Taffy 0.9 API)
326pub fn layout_and_paint(
327    root: &View,
328    size: (u32, u32),
329    textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
330    interactions: &Interactions,
331    focused: Option<u64>,
332) -> (Scene, Vec<HitRegion>, Vec<SemNode>) {
333    // Assign ids
334    let mut id = 1u64;
335    fn stamp(mut v: View, id: &mut u64) -> View {
336        v.id = *id;
337        *id += 1;
338        v.children = v.children.into_iter().map(|c| stamp(c, id)).collect();
339        v
340    }
341    let root = stamp(root.clone(), &mut id);
342
343    // Build Taffy tree (with per-node contexts for measurement)
344    use taffy::prelude::*;
345    #[derive(Clone)]
346    enum NodeCtx {
347        Text { text: String, font_px: f32 },
348        Button { label: String },
349        TextField,
350        Container,
351        Checkbox { label: String },
352        Radio { label: String },
353        Switch { label: String },
354        Slider { label: String },
355        Range { label: String },
356        Progress { label: String },
357    }
358
359    let mut taffy: TaffyTree<NodeCtx> = TaffyTree::new();
360    let mut nodes_map = HashMap::new();
361
362    fn style_from_modifier(m: &Modifier, kind: &ViewKind) -> Style {
363        let mut s = Style::default();
364        s.display = match kind {
365            ViewKind::Row => Display::Flex,
366            ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. } => Display::Flex,
367            ViewKind::Stack => Display::Grid, // stack is grid overlay
368            _ => Display::Flex,
369        };
370        if matches!(kind, ViewKind::Row) {
371            s.flex_direction = FlexDirection::Row;
372        }
373        if matches!(
374            kind,
375            ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. }
376        ) {
377            s.align_items = Some(AlignItems::Stretch);
378        } else {
379            s.align_items = Some(AlignItems::FlexStart);
380        }
381        s.justify_content = Some(JustifyContent::FlexStart);
382
383        if let Some(r) = m.aspect_ratio {
384            s.aspect_ratio = Some(r);
385        }
386
387        // Flex
388        if let Some(g) = m.flex_grow {
389            s.flex_grow = g;
390        }
391        if let Some(sh) = m.flex_shrink {
392            s.flex_shrink = sh;
393        }
394        if let Some(b) = m.flex_basis {
395            s.flex_basis = length(b);
396        }
397
398        // Align self (including baseline)
399        if let Some(a) = m.align_self {
400            s.align_self = Some(a);
401        }
402
403        // Absolute positioning
404        if let Some(repose_core::modifier::PositionType::Absolute) = m.position_type {
405            s.position = Position::Absolute;
406            s.inset = taffy::geometry::Rect {
407                left: m.offset_left.map(length).unwrap_or_else(auto),
408                right: m.offset_right.map(length).unwrap_or_else(auto),
409                top: m.offset_top.map(length).unwrap_or_else(auto),
410                bottom: m.offset_bottom.map(length).unwrap_or_else(auto),
411            };
412        }
413
414        // Grid
415        if let Some(cfg) = &m.grid {
416            s.display = Display::Grid;
417
418            // Explicit N equal columns: repeat Single(flex(1.0)) N times
419            s.grid_template_columns = (0..cfg.columns)
420                .map(|_| GridTemplateComponent::Single(flex(1.0f32)))
421                .collect();
422
423            // Set gaps
424            s.gap = Size {
425                width: length(cfg.column_gap),
426                height: length(cfg.row_gap),
427            };
428        }
429
430        // Overflow for ScrollV
431        if matches!(kind, ViewKind::ScrollV { .. }) {
432            s.overflow = taffy::Point {
433                x: Overflow::Hidden,
434                y: Overflow::Hidden,
435            };
436        }
437
438        if let Some(dir) = flex_dir_for(kind) {
439            s.flex_direction = dir;
440        }
441        if let Some(p) = m.padding {
442            let v = length(p);
443            s.padding = taffy::geometry::Rect {
444                left: v,
445                right: v,
446                top: v,
447                bottom: v,
448            };
449        }
450
451        if let Some(sz) = m.size {
452            if sz.width.is_finite() {
453                s.size.width = length(sz.width);
454            }
455            if sz.height.is_finite() {
456                s.size.height = length(sz.height);
457            }
458        }
459
460        if m.fill_max {
461            s.size.width = percent(1.0);
462            s.size.height = percent(1.0);
463            s.flex_grow = 1.0;
464            s.flex_shrink = 1.0;
465        }
466
467        s.align_items = Some(AlignItems::FlexStart);
468        s.justify_content = Some(JustifyContent::FlexStart);
469        s
470    }
471
472    fn build_node(
473        v: &View,
474        t: &mut TaffyTree<NodeCtx>,
475        nodes_map: &mut HashMap<ViewId, taffy::NodeId>,
476    ) -> taffy::NodeId {
477        let mut style = style_from_modifier(&v.modifier, &v.kind);
478
479        if v.modifier.grid_col_span.is_some() || v.modifier.grid_row_span.is_some() {
480            use taffy::prelude::{GridPlacement, Line};
481
482            if let Some(cs) = v.modifier.grid_col_span {
483                style.grid_column = Line {
484                    start: GridPlacement::Auto,
485                    end: GridPlacement::Span(cs as u16),
486                };
487            }
488            if let Some(rs) = v.modifier.grid_row_span {
489                style.grid_row = Line {
490                    start: GridPlacement::Auto,
491                    end: GridPlacement::Span(rs as u16),
492                };
493            }
494        }
495
496        let children: Vec<_> = v
497            .children
498            .iter()
499            .map(|c| build_node(c, t, nodes_map))
500            .collect();
501
502        let node = match &v.kind {
503            ViewKind::Text {
504                text, font_size, ..
505            } => t
506                .new_leaf_with_context(
507                    style,
508                    NodeCtx::Text {
509                        text: text.clone(),
510                        font_px: *font_size,
511                    },
512                )
513                .unwrap(),
514            ViewKind::Button { text, .. } => t
515                .new_leaf_with_context(
516                    style,
517                    NodeCtx::Button {
518                        label: text.clone(),
519                    },
520                )
521                .unwrap(),
522            ViewKind::TextField { .. } => {
523                t.new_leaf_with_context(style, NodeCtx::TextField).unwrap()
524            }
525            ViewKind::Checkbox { label, .. } => t
526                .new_leaf_with_context(
527                    style,
528                    NodeCtx::Checkbox {
529                        label: label.clone(),
530                    },
531                )
532                .unwrap(),
533            ViewKind::RadioButton { label, .. } => t
534                .new_leaf_with_context(
535                    style,
536                    NodeCtx::Radio {
537                        label: label.clone(),
538                    },
539                )
540                .unwrap(),
541            ViewKind::Switch { label, .. } => t
542                .new_leaf_with_context(
543                    style,
544                    NodeCtx::Switch {
545                        label: label.clone(),
546                    },
547                )
548                .unwrap(),
549            ViewKind::Slider { label, .. } => t
550                .new_leaf_with_context(
551                    style,
552                    NodeCtx::Slider {
553                        label: label.clone(),
554                    },
555                )
556                .unwrap(),
557            ViewKind::RangeSlider { label, .. } => t
558                .new_leaf_with_context(
559                    style,
560                    NodeCtx::Range {
561                        label: label.clone(),
562                    },
563                )
564                .unwrap(),
565            ViewKind::ProgressBar { label, .. } => t
566                .new_leaf_with_context(
567                    style,
568                    NodeCtx::Progress {
569                        label: label.clone(),
570                    },
571                )
572                .unwrap(),
573            _ => {
574                let n = t.new_with_children(style, &children).unwrap();
575                t.set_node_context(n, Some(NodeCtx::Container)).ok();
576                n
577            }
578        };
579
580        nodes_map.insert(v.id, node);
581        node
582    }
583
584    let root_node = build_node(&root, &mut taffy, &mut nodes_map);
585
586    let available = taffy::geometry::Size {
587        width: AvailableSpace::Definite(size.0 as f32),
588        height: AvailableSpace::Definite(size.1 as f32),
589    };
590
591    // Measure function for intrinsic content
592    taffy
593        .compute_layout_with_measure(root_node, available, |known, _avail, _node, ctx, _style| {
594            match ctx {
595                Some(NodeCtx::Text { text, font_px }) => {
596                    let approx_w = text.len() as f32 * *font_px * 0.6;
597                    let w = known.width.unwrap_or(approx_w);
598                    taffy::geometry::Size {
599                        width: w,
600                        height: *font_px * 1.3,
601                    }
602                }
603                Some(NodeCtx::Button { label }) => taffy::geometry::Size {
604                    width: (label.len() as f32 * 16.0 * 0.6) + 24.0,
605                    height: 36.0,
606                },
607                Some(NodeCtx::TextField) => {
608                    let w = known.width.unwrap_or(220.0);
609                    taffy::geometry::Size {
610                        width: w,
611                        height: 36.0,
612                    }
613                }
614                Some(NodeCtx::Checkbox { label }) => {
615                    let label_w = (label.len() as f32) * 16.0 * 0.6;
616                    let w = 24.0 + 8.0 + label_w; // box + gap + text estimate
617                    taffy::geometry::Size {
618                        width: known.width.unwrap_or(w),
619                        height: 24.0,
620                    }
621                }
622                Some(NodeCtx::Radio { label }) => {
623                    let label_w = (label.len() as f32) * 16.0 * 0.6;
624                    let w = 24.0 + 8.0 + label_w; // circle + gap + text estimate
625                    taffy::geometry::Size {
626                        width: known.width.unwrap_or(w),
627                        height: 24.0,
628                    }
629                }
630                Some(NodeCtx::Switch { label }) => {
631                    let label_w = (label.len() as f32) * 16.0 * 0.6;
632                    let w = 46.0 + 8.0 + label_w; // track + gap + text
633                    taffy::geometry::Size {
634                        width: known.width.unwrap_or(w),
635                        height: 28.0,
636                    }
637                }
638                Some(NodeCtx::Slider { label }) => {
639                    let label_w = (label.len() as f32) * 16.0 * 0.6;
640                    let w = (known.width).unwrap_or(200.0f32.max(46.0 + 8.0 + label_w));
641                    taffy::geometry::Size {
642                        width: w,
643                        height: 28.0,
644                    }
645                }
646                Some(NodeCtx::Range { label }) => {
647                    let label_w = (label.len() as f32) * 16.0 * 0.6;
648                    let w = (known.width).unwrap_or(220.0f32.max(46.0 + 8.0 + label_w));
649                    taffy::geometry::Size {
650                        width: w,
651                        height: 28.0,
652                    }
653                }
654                Some(NodeCtx::Progress { label }) => {
655                    let label_w = (label.len() as f32) * 16.0 * 0.6;
656                    let w = (known.width).unwrap_or(200.0f32.max(100.0 + 8.0 + label_w));
657                    taffy::geometry::Size {
658                        width: w,
659                        height: 12.0 + 8.0,
660                    } // track + small padding
661                }
662                Some(NodeCtx::Container) | None => taffy::geometry::Size::ZERO,
663            }
664        })
665        .unwrap();
666
667    // eprintln!(
668    //     "win {:?}x{:?} root {:?}",
669    //     size.0,
670    //     size.1,
671    //     taffy.layout(root_node).unwrap().size
672    // );
673
674    fn layout_of(node: taffy::NodeId, t: &TaffyTree<impl Clone>) -> repose_core::Rect {
675        let l = t.layout(node).unwrap();
676        repose_core::Rect {
677            x: l.location.x,
678            y: l.location.y,
679            w: l.size.width,
680            h: l.size.height,
681        }
682    }
683
684    fn add_offset(mut r: repose_core::Rect, off: (f32, f32)) -> repose_core::Rect {
685        r.x += off.0;
686        r.y += off.1;
687        r
688    }
689
690    fn clamp01(x: f32) -> f32 {
691        x.max(0.0).min(1.0)
692    }
693    fn norm(value: f32, min: f32, max: f32) -> f32 {
694        if max > min {
695            (value - min) / (max - min)
696        } else {
697            0.0
698        }
699    }
700    fn denorm(t: f32, min: f32, max: f32) -> f32 {
701        min + t * (max - min)
702    }
703    fn snap_step(v: f32, step: Option<f32>, min: f32, max: f32) -> f32 {
704        match step {
705            Some(s) if s > 0.0 => {
706                let k = ((v - min) / s).round();
707                (min + k * s).clamp(min, max)
708            }
709            _ => v.clamp(min, max),
710        }
711    }
712
713    let mut scene = Scene {
714        clear_color: locals::theme().background,
715        nodes: vec![],
716    };
717    let mut hits: Vec<HitRegion> = vec![];
718    let mut sems: Vec<SemNode> = vec![];
719
720    fn walk(
721        v: &View,
722        t: &TaffyTree<NodeCtx>,
723        nodes: &HashMap<ViewId, taffy::NodeId>,
724        scene: &mut Scene,
725        hits: &mut Vec<HitRegion>,
726        sems: &mut Vec<SemNode>,
727        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
728        interactions: &Interactions,
729        focused: Option<u64>,
730        parent_offset: (f32, f32),
731    ) {
732        let local = layout_of(nodes[&v.id], t);
733        let rect = add_offset(local, parent_offset);
734        let base = (parent_offset.0 + local.x, parent_offset.1 + local.y);
735
736        let is_hovered = interactions.hover == Some(v.id);
737        let is_pressed = interactions.pressed.contains(&v.id);
738        let is_focused = focused == Some(v.id);
739
740        // Background/border (unchanged, but use 'rect')
741        if let Some(bg) = v.modifier.background {
742            scene.nodes.push(SceneNode::Rect {
743                rect,
744                color: bg,
745                radius: v.modifier.clip_rounded.unwrap_or(0.0),
746            });
747        }
748
749        // Border
750        if let Some(b) = &v.modifier.border {
751            scene.nodes.push(SceneNode::Border {
752                rect,
753                color: b.color,
754                width: b.width,
755                radius: b.radius.max(v.modifier.clip_rounded.unwrap_or(0.0)),
756            });
757        }
758
759        let has_pointer = v.modifier.on_pointer_down.is_some()
760            || v.modifier.on_pointer_move.is_some()
761            || v.modifier.on_pointer_up.is_some()
762            || v.modifier.on_pointer_enter.is_some()
763            || v.modifier.on_pointer_leave.is_some();
764
765        if has_pointer || v.modifier.click {
766            hits.push(HitRegion {
767                id: v.id,
768                rect,
769                on_click: None,  // unless ViewKind provides one
770                on_scroll: None, // provided by ScrollV case
771                focusable: false,
772                on_pointer_down: v.modifier.on_pointer_down.clone(),
773                on_pointer_move: v.modifier.on_pointer_move.clone(),
774                on_pointer_up: v.modifier.on_pointer_up.clone(),
775                on_pointer_enter: v.modifier.on_pointer_enter.clone(),
776                on_pointer_leave: v.modifier.on_pointer_leave.clone(),
777                z_index: v.modifier.z_index,
778                on_text_change: None,
779            });
780        }
781
782        match &v.kind {
783            ViewKind::Text {
784                text,
785                color,
786                font_size,
787            } => {
788                // Apply text scale from CompositionLocal
789                let scaled_size = *font_size * locals::text_scale().0;
790                scene.nodes.push(SceneNode::Text {
791                    rect,
792                    text: text.clone(),
793                    color: *color,
794                    size: scaled_size,
795                });
796                sems.push(SemNode {
797                    id: v.id,
798                    role: Role::Text,
799                    label: Some(text.clone()),
800                    rect,
801                    focused: is_focused,
802                    enabled: true,
803                });
804            }
805
806            ViewKind::Button { text, on_click } => {
807                // Default background if none provided
808                if v.modifier.background.is_none() {
809                    let base = if is_pressed {
810                        Color::from_hex("#1f7556")
811                    } else if is_hovered {
812                        Color::from_hex("#2a8f6a")
813                    } else {
814                        Color::from_hex("#34af82")
815                    };
816                    scene.nodes.push(SceneNode::Rect {
817                        rect,
818                        color: base,
819                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
820                    });
821                }
822                // Label
823                scene.nodes.push(SceneNode::Text {
824                    rect: repose_core::Rect {
825                        x: rect.x + 12.0,
826                        y: rect.y + 10.0,
827                        w: rect.w - 24.0,
828                        h: rect.h - 20.0,
829                    },
830                    text: text.clone(),
831                    color: Color::WHITE,
832                    size: 16.0,
833                });
834
835                if v.modifier.click || on_click.is_some() {
836                    hits.push(HitRegion {
837                        id: v.id,
838                        rect,
839                        on_click: on_click.clone(),
840                        on_scroll: None,
841                        focusable: true,
842                        on_pointer_down: v.modifier.on_pointer_down.clone(),
843                        on_pointer_move: v.modifier.on_pointer_move.clone(),
844                        on_pointer_up: v.modifier.on_pointer_up.clone(),
845                        on_pointer_enter: v.modifier.on_pointer_enter.clone(),
846                        on_pointer_leave: v.modifier.on_pointer_leave.clone(),
847                        z_index: v.modifier.z_index,
848                        on_text_change: None,
849                    });
850                }
851                sems.push(SemNode {
852                    id: v.id,
853                    role: Role::Button,
854                    label: Some(text.clone()),
855                    rect,
856                    focused: is_focused,
857                    enabled: true,
858                });
859                // Focus ring
860                if is_focused {
861                    scene.nodes.push(SceneNode::Border {
862                        rect,
863                        color: Color::from_hex("#88CCFF"),
864                        width: 2.0,
865                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
866                    });
867                }
868            }
869
870            ViewKind::TextField {
871                hint, on_change, ..
872            } => {
873                hits.push(HitRegion {
874                    id: v.id,
875                    rect,
876                    on_click: None,
877                    on_scroll: None,
878                    focusable: true,
879                    on_pointer_down: None,
880                    on_pointer_move: None,
881                    on_pointer_up: None,
882                    on_pointer_enter: None,
883                    on_pointer_leave: None,
884                    z_index: v.modifier.z_index,
885                    on_text_change: on_change.clone(),
886                });
887
888                // Inner content rect (padding)
889                let inner = repose_core::Rect {
890                    x: rect.x + TF_PADDING_X,
891                    y: rect.y + 8.0,
892                    w: rect.w - 2.0 * TF_PADDING_X,
893                    h: rect.h - 16.0,
894                };
895                scene.nodes.push(SceneNode::PushClip {
896                    rect: inner,
897                    radius: 0.0,
898                });
899                // TextField focus ring
900                if is_focused {
901                    scene.nodes.push(SceneNode::Border {
902                        rect,
903                        color: Color::from_hex("#88CCFF"),
904                        width: 2.0,
905                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
906                    });
907                }
908                if let Some(state_rc) = textfield_states.get(&v.id) {
909                    state_rc.borrow_mut().set_inner_width(inner.w);
910
911                    let state = state_rc.borrow();
912                    let text = &state.text;
913                    let px = TF_FONT_PX as u32;
914                    let m = measure_text(text, px);
915
916                    // Selection highlight
917                    if state.selection.start != state.selection.end {
918                        let i0 = byte_to_char_index(&m, state.selection.start);
919                        let i1 = byte_to_char_index(&m, state.selection.end);
920                        let sx = m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
921                        let ex = m.positions.get(i1).copied().unwrap_or(sx) - state.scroll_offset;
922                        let sel_x = inner.x + sx.max(0.0);
923                        let sel_w = (ex - sx).max(0.0);
924                        scene.nodes.push(SceneNode::Rect {
925                            rect: repose_core::Rect {
926                                x: sel_x,
927                                y: inner.y,
928                                w: sel_w,
929                                h: inner.h,
930                            },
931                            color: Color::from_hex("#3B7BFF55"),
932                            radius: 0.0,
933                        });
934                    }
935
936                    // Composition underline
937                    if let Some(range) = &state.composition {
938                        if range.start < range.end && !text.is_empty() {
939                            let i0 = byte_to_char_index(&m, range.start);
940                            let i1 = byte_to_char_index(&m, range.end);
941                            let sx =
942                                m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
943                            let ex =
944                                m.positions.get(i1).copied().unwrap_or(sx) - state.scroll_offset;
945                            let ux = inner.x + sx.max(0.0);
946                            let uw = (ex - sx).max(0.0);
947                            scene.nodes.push(SceneNode::Rect {
948                                rect: repose_core::Rect {
949                                    x: ux,
950                                    y: inner.y + inner.h - 2.0,
951                                    w: uw,
952                                    h: 2.0,
953                                },
954                                color: Color::from_hex("#88CCFF"),
955                                radius: 0.0,
956                            });
957                        }
958                    }
959
960                    // Text (offset by scroll)
961                    scene.nodes.push(SceneNode::Text {
962                        rect: repose_core::Rect {
963                            x: inner.x - state.scroll_offset,
964                            y: inner.y,
965                            w: inner.w,
966                            h: inner.h,
967                        },
968                        text: if text.is_empty() {
969                            hint.clone()
970                        } else {
971                            text.clone()
972                        },
973                        color: if text.is_empty() {
974                            Color::from_hex("#666666")
975                        } else {
976                            Color::from_hex("#CCCCCC")
977                        },
978                        size: TF_FONT_PX,
979                    });
980
981                    // Caret (blink)
982                    if state.selection.start == state.selection.end && state.caret_visible() {
983                        let i = byte_to_char_index(&m, state.selection.end);
984                        let cx = m.positions.get(i).copied().unwrap_or(0.0) - state.scroll_offset;
985                        let caret_x = inner.x + cx.max(0.0);
986                        scene.nodes.push(SceneNode::Rect {
987                            rect: repose_core::Rect {
988                                x: caret_x,
989                                y: inner.y,
990                                w: 1.0,
991                                h: inner.h,
992                            },
993                            color: Color::WHITE,
994                            radius: 0.0,
995                        });
996                    }
997                    // end inner clip
998                    scene.nodes.push(SceneNode::PopClip);
999
1000                    sems.push(SemNode {
1001                        id: v.id,
1002                        role: Role::TextField,
1003                        label: Some(text.clone()),
1004                        rect,
1005                        focused: is_focused,
1006                        enabled: true,
1007                    });
1008                } else {
1009                    // No state yet: show hint only
1010                    scene.nodes.push(SceneNode::Text {
1011                        rect: repose_core::Rect {
1012                            x: inner.x,
1013                            y: inner.y,
1014                            w: inner.w,
1015                            h: inner.h,
1016                        },
1017                        text: hint.clone(),
1018                        color: Color::from_hex("#666666"),
1019                        size: TF_FONT_PX,
1020                    });
1021                    sems.push(SemNode {
1022                        id: v.id,
1023                        role: Role::TextField,
1024                        label: Some(hint.clone()),
1025                        rect,
1026                        focused: is_focused,
1027                        enabled: true,
1028                    });
1029                }
1030            }
1031            ViewKind::ScrollV {
1032                on_scroll,
1033                set_viewport_height,
1034                get_scroll_offset,
1035            } => {
1036                // Register hit region (use local rect for hit testing)
1037                hits.push(HitRegion {
1038                    id: v.id,
1039                    rect, // viewport in global coords
1040                    on_click: None,
1041                    on_scroll: on_scroll.clone(),
1042                    focusable: false,
1043                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1044                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1045                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1046                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1047                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1048                    z_index: v.modifier.z_index,
1049                    on_text_change: None,
1050                });
1051
1052                // Report viewport height
1053                if let Some(set_vh) = set_viewport_height {
1054                    set_vh(local.h); // use local height before offsets
1055                }
1056
1057                // Clip to viewport
1058                scene.nodes.push(SceneNode::PushClip {
1059                    rect,
1060                    radius: v.modifier.clip_rounded.unwrap_or(0.0),
1061                });
1062
1063                // Child offset includes scroll translation (0, -scroll_offset)
1064                let mut child_offset = base;
1065                if let Some(get) = get_scroll_offset {
1066                    child_offset.1 -= get();
1067                }
1068
1069                for c in &v.children {
1070                    walk(
1071                        c,
1072                        t,
1073                        nodes,
1074                        scene,
1075                        hits,
1076                        sems,
1077                        textfield_states,
1078                        interactions,
1079                        focused,
1080                        child_offset,
1081                    );
1082                }
1083
1084                scene.nodes.push(SceneNode::PopClip);
1085                return; // done with children
1086            }
1087            ViewKind::Checkbox {
1088                checked,
1089                label,
1090                on_change,
1091            } => {
1092                let theme = locals::theme();
1093                // Box at left (20x20 centered vertically)
1094                let box_size = 18.0f32;
1095                let bx = rect.x;
1096                let by = rect.y + (rect.h - box_size) * 0.5;
1097                // box bg/border
1098                scene.nodes.push(SceneNode::Rect {
1099                    rect: repose_core::Rect {
1100                        x: bx,
1101                        y: by,
1102                        w: box_size,
1103                        h: box_size,
1104                    },
1105                    color: if *checked {
1106                        theme.primary
1107                    } else {
1108                        theme.surface
1109                    },
1110                    radius: 3.0,
1111                });
1112                scene.nodes.push(SceneNode::Border {
1113                    rect: repose_core::Rect {
1114                        x: bx,
1115                        y: by,
1116                        w: box_size,
1117                        h: box_size,
1118                    },
1119                    color: Color::from_hex("#555555"),
1120                    width: 1.0,
1121                    radius: 3.0,
1122                });
1123                // checkmark
1124                if *checked {
1125                    scene.nodes.push(SceneNode::Text {
1126                        rect: repose_core::Rect {
1127                            x: bx + 3.0,
1128                            y: by + 1.0,
1129                            w: box_size,
1130                            h: box_size,
1131                        },
1132                        text: "✓".to_string(),
1133                        color: theme.on_primary,
1134                        size: 16.0,
1135                    });
1136                }
1137                // label
1138                scene.nodes.push(SceneNode::Text {
1139                    rect: repose_core::Rect {
1140                        x: bx + box_size + 8.0,
1141                        y: rect.y,
1142                        w: rect.w - (box_size + 8.0),
1143                        h: rect.h,
1144                    },
1145                    text: label.clone(),
1146                    color: theme.on_surface,
1147                    size: 16.0,
1148                });
1149
1150                // Hit + semantics + focus ring
1151                let toggled = !*checked;
1152                let on_click = on_change.as_ref().map(|cb| {
1153                    let cb = cb.clone();
1154                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
1155                });
1156                hits.push(HitRegion {
1157                    id: v.id,
1158                    rect,
1159                    on_click,
1160                    on_scroll: None,
1161                    focusable: true,
1162                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1163                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1164                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1165                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1166                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1167                    z_index: v.modifier.z_index,
1168                    on_text_change: None,
1169                });
1170                sems.push(SemNode {
1171                    id: v.id,
1172                    role: Role::Checkbox,
1173                    label: Some(label.clone()),
1174                    rect,
1175                    focused: is_focused,
1176                    enabled: true,
1177                });
1178                if is_focused {
1179                    scene.nodes.push(SceneNode::Border {
1180                        rect,
1181                        color: Color::from_hex("#88CCFF"),
1182                        width: 2.0,
1183                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
1184                    });
1185                }
1186            }
1187
1188            ViewKind::RadioButton {
1189                selected,
1190                label,
1191                on_select,
1192            } => {
1193                let theme = locals::theme();
1194                let d = 18.0f32;
1195                let cx = rect.x;
1196                let cy = rect.y + (rect.h - d) * 0.5;
1197
1198                // outer circle (rounded rect as circle)
1199                scene.nodes.push(SceneNode::Border {
1200                    rect: repose_core::Rect {
1201                        x: cx,
1202                        y: cy,
1203                        w: d,
1204                        h: d,
1205                    },
1206                    color: Color::from_hex("#888888"),
1207                    width: 1.5,
1208                    radius: d * 0.5,
1209                });
1210                // inner dot if selected
1211                if *selected {
1212                    scene.nodes.push(SceneNode::Rect {
1213                        rect: repose_core::Rect {
1214                            x: cx + 4.0,
1215                            y: cy + 4.0,
1216                            w: d - 8.0,
1217                            h: d - 8.0,
1218                        },
1219                        color: theme.primary,
1220                        radius: (d - 8.0) * 0.5,
1221                    });
1222                }
1223                scene.nodes.push(SceneNode::Text {
1224                    rect: repose_core::Rect {
1225                        x: cx + d + 8.0,
1226                        y: rect.y,
1227                        w: rect.w - (d + 8.0),
1228                        h: rect.h,
1229                    },
1230                    text: label.clone(),
1231                    color: theme.on_surface,
1232                    size: 16.0,
1233                });
1234
1235                hits.push(HitRegion {
1236                    id: v.id,
1237                    rect,
1238                    on_click: on_select.clone(),
1239                    on_scroll: None,
1240                    focusable: true,
1241                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1242                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1243                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1244                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1245                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1246                    z_index: v.modifier.z_index,
1247                    on_text_change: None,
1248                });
1249                sems.push(SemNode {
1250                    id: v.id,
1251                    role: Role::RadioButton,
1252                    label: Some(label.clone()),
1253                    rect,
1254                    focused: is_focused,
1255                    enabled: true,
1256                });
1257                if is_focused {
1258                    scene.nodes.push(SceneNode::Border {
1259                        rect,
1260                        color: Color::from_hex("#88CCFF"),
1261                        width: 2.0,
1262                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
1263                    });
1264                }
1265            }
1266
1267            ViewKind::Switch {
1268                checked,
1269                label,
1270                on_change,
1271            } => {
1272                let theme = locals::theme();
1273                // track 46x26, knob 22x22
1274                let track_w = 46.0f32;
1275                let track_h = 26.0f32;
1276                let tx = rect.x;
1277                let ty = rect.y + (rect.h - track_h) * 0.5;
1278                let knob = 22.0f32;
1279                let on_col = theme.primary;
1280                let off_col = Color::from_hex("#333333");
1281
1282                // track
1283                scene.nodes.push(SceneNode::Rect {
1284                    rect: repose_core::Rect {
1285                        x: tx,
1286                        y: ty,
1287                        w: track_w,
1288                        h: track_h,
1289                    },
1290                    color: if *checked { on_col } else { off_col },
1291                    radius: track_h * 0.5,
1292                });
1293                // knob position
1294                let kx = if *checked {
1295                    tx + track_w - knob - 2.0
1296                } else {
1297                    tx + 2.0
1298                };
1299                let ky = ty + (track_h - knob) * 0.5;
1300                scene.nodes.push(SceneNode::Rect {
1301                    rect: repose_core::Rect {
1302                        x: kx,
1303                        y: ky,
1304                        w: knob,
1305                        h: knob,
1306                    },
1307                    color: Color::from_hex("#EEEEEE"),
1308                    radius: knob * 0.5,
1309                });
1310
1311                // label
1312                scene.nodes.push(SceneNode::Text {
1313                    rect: repose_core::Rect {
1314                        x: tx + track_w + 8.0,
1315                        y: rect.y,
1316                        w: rect.w - (track_w + 8.0),
1317                        h: rect.h,
1318                    },
1319                    text: label.clone(),
1320                    color: theme.on_surface,
1321                    size: 16.0,
1322                });
1323
1324                let toggled = !*checked;
1325                let on_click = on_change.as_ref().map(|cb| {
1326                    let cb = cb.clone();
1327                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
1328                });
1329                hits.push(HitRegion {
1330                    id: v.id,
1331                    rect,
1332                    on_click,
1333                    on_scroll: None,
1334                    focusable: true,
1335                    on_pointer_down: v.modifier.on_pointer_down.clone(),
1336                    on_pointer_move: v.modifier.on_pointer_move.clone(),
1337                    on_pointer_up: v.modifier.on_pointer_up.clone(),
1338                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1339                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1340                    z_index: v.modifier.z_index,
1341                    on_text_change: None,
1342                });
1343                sems.push(SemNode {
1344                    id: v.id,
1345                    role: Role::Switch,
1346                    label: Some(label.clone()),
1347                    rect,
1348                    focused: is_focused,
1349                    enabled: true,
1350                });
1351                if is_focused {
1352                    scene.nodes.push(SceneNode::Border {
1353                        rect,
1354                        color: Color::from_hex("#88CCFF"),
1355                        width: 2.0,
1356                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
1357                    });
1358                }
1359            }
1360            ViewKind::Slider {
1361                value,
1362                min,
1363                max,
1364                step,
1365                label,
1366                on_change,
1367            } => {
1368                let theme = locals::theme();
1369                // Layout: [track | label]
1370                let track_h = 4.0f32;
1371                let knob_d = 20.0f32;
1372                let gap = 8.0f32;
1373                let label_x = rect.x + rect.w * 0.6; // simple split: 60% track, 40% label
1374                let track_x = rect.x;
1375                let track_w = (label_x - track_x).max(60.0);
1376                let cy = rect.y + rect.h * 0.5;
1377
1378                // Track
1379                scene.nodes.push(SceneNode::Rect {
1380                    rect: repose_core::Rect {
1381                        x: track_x,
1382                        y: cy - track_h * 0.5,
1383                        w: track_w,
1384                        h: track_h,
1385                    },
1386                    color: Color::from_hex("#333333"),
1387                    radius: track_h * 0.5,
1388                });
1389
1390                // Knob position
1391                let t = clamp01(norm(*value, *min, *max));
1392                let kx = track_x + t * track_w;
1393                scene.nodes.push(SceneNode::Rect {
1394                    rect: repose_core::Rect {
1395                        x: kx - knob_d * 0.5,
1396                        y: cy - knob_d * 0.5,
1397                        w: knob_d,
1398                        h: knob_d,
1399                    },
1400                    color: theme.surface,
1401                    radius: knob_d * 0.5,
1402                });
1403                scene.nodes.push(SceneNode::Border {
1404                    rect: repose_core::Rect {
1405                        x: kx - knob_d * 0.5,
1406                        y: cy - knob_d * 0.5,
1407                        w: knob_d,
1408                        h: knob_d,
1409                    },
1410                    color: Color::from_hex("#888888"),
1411                    width: 1.0,
1412                    radius: knob_d * 0.5,
1413                });
1414
1415                // Label
1416                scene.nodes.push(SceneNode::Text {
1417                    rect: repose_core::Rect {
1418                        x: label_x + gap,
1419                        y: rect.y,
1420                        w: rect.x + rect.w - (label_x + gap),
1421                        h: rect.h,
1422                    },
1423                    text: format!("{}: {:.2}", label, *value),
1424                    color: theme.on_surface,
1425                    size: 16.0,
1426                });
1427
1428                // Interactions
1429                let on_change_cb: Option<Rc<dyn Fn(f32)>> = on_change.as_ref().cloned();
1430                let minv = *min;
1431                let maxv = *max;
1432                let stepv = *step;
1433
1434                // per-hit-region current value (wheel deltas accumulate within a frame)
1435                let current = Rc::new(RefCell::new(*value));
1436
1437                // pointer mapping closure (in global coords)
1438                let update_at = {
1439                    let on_change_cb = on_change_cb.clone();
1440                    let current = current.clone();
1441                    Rc::new(move |px: f32| {
1442                        let tt = clamp01((px - track_x) / track_w);
1443                        let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
1444                        *current.borrow_mut() = v;
1445                        if let Some(cb) = &on_change_cb {
1446                            cb(v);
1447                        }
1448                    })
1449                };
1450
1451                // on_pointer_down: update once at press
1452                let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1453                    let f = update_at.clone();
1454                    Rc::new(move |pe| {
1455                        f(pe.position.x);
1456                    })
1457                };
1458
1459                // on_pointer_move: no gating inside; platform only delivers here while captured
1460                let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1461                    let f = update_at.clone();
1462                    Rc::new(move |pe| {
1463                        f(pe.position.x);
1464                    })
1465                };
1466
1467                // on_pointer_up: no-op
1468                let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
1469
1470                // Mouse wheel nudge: accumulate via 'current'
1471                let on_scroll = {
1472                    let on_change_cb = on_change_cb.clone();
1473                    let current = current.clone();
1474                    Rc::new(move |dy: f32| -> f32 {
1475                        let base = *current.borrow();
1476                        let delta = stepv.unwrap_or((maxv - minv) * 0.01);
1477                        // winit: negative dy for wheel-up; treat as increase
1478                        let dir = if dy.is_sign_negative() { 1.0 } else { -1.0 };
1479                        let new_v = snap_step(base + dir * delta, stepv, minv, maxv);
1480                        *current.borrow_mut() = new_v;
1481                        if let Some(cb) = &on_change_cb {
1482                            cb(new_v);
1483                        }
1484                        0.0
1485                    })
1486                };
1487
1488                // Register move handler only while pressed so hover doesn't change value
1489                hits.push(HitRegion {
1490                    id: v.id,
1491                    rect,
1492                    on_click: None,
1493                    on_scroll: Some(on_scroll),
1494                    focusable: true,
1495                    on_pointer_down: Some(on_pd),
1496                    on_pointer_move: if is_pressed { Some(on_pm) } else { None },
1497                    on_pointer_up: Some(on_pu),
1498                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1499                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1500                    z_index: v.modifier.z_index,
1501                    on_text_change: None,
1502                });
1503
1504                sems.push(SemNode {
1505                    id: v.id,
1506                    role: Role::Slider,
1507                    label: Some(label.clone()),
1508                    rect,
1509                    focused: is_focused,
1510                    enabled: true,
1511                });
1512                if is_focused {
1513                    scene.nodes.push(SceneNode::Border {
1514                        rect,
1515                        color: Color::from_hex("#88CCFF"),
1516                        width: 2.0,
1517                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
1518                    });
1519                }
1520            }
1521            ViewKind::RangeSlider {
1522                start,
1523                end,
1524                min,
1525                max,
1526                step,
1527                label,
1528                on_change,
1529            } => {
1530                let theme = locals::theme();
1531                let track_h = 4.0f32;
1532                let knob_d = 20.0f32;
1533                let gap = 8.0f32;
1534                let label_x = rect.x + rect.w * 0.6;
1535                let track_x = rect.x;
1536                let track_w = (label_x - track_x).max(80.0);
1537                let cy = rect.y + rect.h * 0.5;
1538
1539                // Track
1540                scene.nodes.push(SceneNode::Rect {
1541                    rect: repose_core::Rect {
1542                        x: track_x,
1543                        y: cy - track_h * 0.5,
1544                        w: track_w,
1545                        h: track_h,
1546                    },
1547                    color: Color::from_hex("#333333"),
1548                    radius: track_h * 0.5,
1549                });
1550
1551                // Positions
1552                let t0 = clamp01(norm(*start, *min, *max));
1553                let t1 = clamp01(norm(*end, *min, *max));
1554                let k0x = track_x + t0 * track_w;
1555                let k1x = track_x + t1 * track_w;
1556
1557                // Range fill
1558                scene.nodes.push(SceneNode::Rect {
1559                    rect: repose_core::Rect {
1560                        x: k0x.min(k1x),
1561                        y: cy - track_h * 0.5,
1562                        w: (k1x - k0x).abs(),
1563                        h: track_h,
1564                    },
1565                    color: theme.primary,
1566                    radius: track_h * 0.5,
1567                });
1568
1569                // Knobs
1570                for &kx in &[k0x, k1x] {
1571                    scene.nodes.push(SceneNode::Rect {
1572                        rect: repose_core::Rect {
1573                            x: kx - knob_d * 0.5,
1574                            y: cy - knob_d * 0.5,
1575                            w: knob_d,
1576                            h: knob_d,
1577                        },
1578                        color: theme.surface,
1579                        radius: knob_d * 0.5,
1580                    });
1581                    scene.nodes.push(SceneNode::Border {
1582                        rect: repose_core::Rect {
1583                            x: kx - knob_d * 0.5,
1584                            y: cy - knob_d * 0.5,
1585                            w: knob_d,
1586                            h: knob_d,
1587                        },
1588                        color: Color::from_hex("#888888"),
1589                        width: 1.0,
1590                        radius: knob_d * 0.5,
1591                    });
1592                }
1593
1594                // Label
1595                scene.nodes.push(SceneNode::Text {
1596                    rect: repose_core::Rect {
1597                        x: label_x + gap,
1598                        y: rect.y,
1599                        w: rect.x + rect.w - (label_x + gap),
1600                        h: rect.h,
1601                    },
1602                    text: format!("{}: {:.2} – {:.2}", label, *start, *end),
1603                    color: theme.on_surface,
1604                    size: 16.0,
1605                });
1606
1607                // Interaction
1608                let on_change_cb = on_change.as_ref().cloned();
1609                let minv = *min;
1610                let maxv = *max;
1611                let stepv = *step;
1612                let start_val = *start;
1613                let end_val = *end;
1614
1615                // which thumb is active during drag: Some(0) or Some(1)
1616                let active = Rc::new(RefCell::new(None::<u8>));
1617
1618                // update for current active thumb; does nothing if None
1619                let update = {
1620                    let active = active.clone();
1621                    let on_change_cb = on_change_cb.clone();
1622                    Rc::new(move |px: f32| {
1623                        if let Some(thumb) = *active.borrow() {
1624                            let tt = clamp01((px - track_x) / track_w);
1625                            let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
1626                            match thumb {
1627                                0 => {
1628                                    let new_start = v.min(end_val).min(maxv).max(minv);
1629                                    if let Some(cb) = &on_change_cb {
1630                                        cb(new_start, end_val);
1631                                    }
1632                                }
1633                                _ => {
1634                                    let new_end = v.max(start_val).max(minv).min(maxv);
1635                                    if let Some(cb) = &on_change_cb {
1636                                        cb(start_val, new_end);
1637                                    }
1638                                }
1639                            }
1640                        }
1641                    })
1642                };
1643
1644                // on_pointer_down: choose nearest thumb and update once
1645                let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1646                    let active = active.clone();
1647                    let update = update.clone();
1648                    // snapshot thumb positions for hit decision
1649                    let k0x0 = k0x;
1650                    let k1x0 = k1x;
1651                    Rc::new(move |pe| {
1652                        let px = pe.position.x;
1653                        let d0 = (px - k0x0).abs();
1654                        let d1 = (px - k1x0).abs();
1655                        *active.borrow_mut() = Some(if d0 <= d1 { 0 } else { 1 });
1656                        update(px);
1657                    })
1658                };
1659
1660                // on_pointer_move: update only while a thumb is active
1661                let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1662                    let active = active.clone();
1663                    let update = update.clone();
1664                    Rc::new(move |pe| {
1665                        if active.borrow().is_some() {
1666                            update(pe.position.x);
1667                        }
1668                    })
1669                };
1670
1671                // on_pointer_up: clear active thumb
1672                let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1673                    let active = active.clone();
1674                    Rc::new(move |_pe| {
1675                        *active.borrow_mut() = None;
1676                    })
1677                };
1678
1679                hits.push(HitRegion {
1680                    id: v.id,
1681                    rect,
1682                    on_click: None,
1683                    on_scroll: None,
1684                    focusable: true,
1685                    on_pointer_down: Some(on_pd),
1686                    on_pointer_move: Some(on_pm),
1687                    on_pointer_up: Some(on_pu),
1688                    on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1689                    on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1690                    z_index: v.modifier.z_index,
1691                    on_text_change: None,
1692                });
1693                sems.push(SemNode {
1694                    id: v.id,
1695                    role: Role::Slider,
1696                    label: Some(label.clone()),
1697                    rect,
1698                    focused: is_focused,
1699                    enabled: true,
1700                });
1701                if is_focused {
1702                    scene.nodes.push(SceneNode::Border {
1703                        rect,
1704                        color: Color::from_hex("#88CCFF"),
1705                        width: 2.0,
1706                        radius: v.modifier.clip_rounded.unwrap_or(6.0),
1707                    });
1708                }
1709            }
1710            ViewKind::ProgressBar {
1711                value,
1712                min,
1713                max,
1714                label,
1715                circular,
1716            } => {
1717                let theme = locals::theme();
1718                let track_h = 6.0f32;
1719                let gap = 8.0f32;
1720                let label_w_split = rect.w * 0.6;
1721                let track_x = rect.x;
1722                let track_w = (label_w_split - track_x).max(60.0);
1723                let cy = rect.y + rect.h * 0.5;
1724
1725                scene.nodes.push(SceneNode::Rect {
1726                    rect: repose_core::Rect {
1727                        x: track_x,
1728                        y: cy - track_h * 0.5,
1729                        w: track_w,
1730                        h: track_h,
1731                    },
1732                    color: Color::from_hex("#333333"),
1733                    radius: track_h * 0.5,
1734                });
1735
1736                let t = clamp01(norm(*value, *min, *max));
1737                scene.nodes.push(SceneNode::Rect {
1738                    rect: repose_core::Rect {
1739                        x: track_x,
1740                        y: cy - track_h * 0.5,
1741                        w: track_w * t,
1742                        h: track_h,
1743                    },
1744                    color: theme.primary,
1745                    radius: track_h * 0.5,
1746                });
1747
1748                scene.nodes.push(SceneNode::Text {
1749                    rect: repose_core::Rect {
1750                        x: rect.x + label_w_split + gap,
1751                        y: rect.y,
1752                        w: rect.w - (label_w_split + gap),
1753                        h: rect.h,
1754                    },
1755                    text: format!("{}: {:.0}%", label, t * 100.0),
1756                    color: theme.on_surface,
1757                    size: 16.0,
1758                });
1759
1760                sems.push(SemNode {
1761                    id: v.id,
1762                    role: Role::ProgressBar,
1763                    label: Some(label.clone()),
1764                    rect,
1765                    focused: is_focused,
1766                    enabled: true,
1767                });
1768            }
1769
1770            _ => {}
1771        }
1772
1773        // Recurse (no extra clip by default)
1774        for c in &v.children {
1775            walk(
1776                c,
1777                t,
1778                nodes,
1779                scene,
1780                hits,
1781                sems,
1782                textfield_states,
1783                interactions,
1784                focused,
1785                base,
1786            );
1787        }
1788    }
1789
1790    // Start with zero offset
1791    walk(
1792        &root,
1793        &taffy,
1794        &nodes_map,
1795        &mut scene,
1796        &mut hits,
1797        &mut sems,
1798        textfield_states,
1799        interactions,
1800        focused,
1801        (0.0, 0.0),
1802    );
1803
1804    // Ensure visual order: low z_index first. Topmost will be found by iter().rev().
1805    hits.sort_by(|a, b| a.z_index.partial_cmp(&b.z_index).unwrap_or(Ordering::Equal));
1806
1807    (scene, hits, sems)
1808}