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