repose_ui/
lib.rs

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