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