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