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