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