Skip to main content

repose_ui/
layout.rs

1#![allow(non_snake_case)]
2
3use std::cell::RefCell;
4use std::cmp::Ordering;
5use std::collections::HashMap;
6use std::hash::{Hash, Hasher};
7use std::rc::Rc;
8use std::sync::Arc;
9
10use repose_core::*;
11use repose_tree::{NodeId, TreeNode, TreeStats, ViewTree};
12use rustc_hash::{FxHashMap, FxHashSet, FxHasher};
13use taffy::TaffyTree;
14use taffy::prelude::*;
15use taffy::style::FlexDirection;
16use taffy::style::Overflow;
17
18use crate::Interactions;
19use crate::textfield::{
20    TF_FONT_DP, TF_PADDING_X_DP, TextFieldState, byte_to_char_index, measure_text,
21};
22
23/// The incremental layout engine.
24pub struct LayoutEngine {
25    /// Persistent view tree.
26    tree: ViewTree,
27
28    /// Taffy layout tree.
29    taffy: TaffyTree<NodeContext>,
30
31    /// Map from ViewTree NodeId to Taffy NodeId.
32    taffy_map: FxHashMap<NodeId, taffy::NodeId>,
33
34    /// Reverse map: Taffy NodeId to ViewTree NodeId.
35    reverse_map: FxHashMap<taffy::NodeId, NodeId>,
36
37    /// Cached text layouts (persists across frames).
38    text_cache: FxHashMap<NodeId, TextLayout>,
39
40    /// Last window size used for layout.
41    last_size_px: Option<(u32, u32)>,
42
43    /// Whether Taffy has a valid computed layout for `last_size_px`.
44    layout_valid: bool,
45
46    /// Repaint-boundary cache (SceneNodes + hits + semantics).
47    paint_cache: FxHashMap<NodeId, PaintCacheEntry>,
48
49    /// Statistics from the last frame.
50    pub stats: LayoutStats,
51
52    /// Last "locals" stamp used for layout decisions (density/text scale/dir).
53    last_locals_stamp: Option<u64>,
54
55    /// Stable, unique ViewId per ViewTree NodeId.
56    view_ids: FxHashMap<NodeId, u64>,
57    next_view_id: u64,
58
59    /// Slider drag state - tracks which sliders are being dragged.
60    slider_dragging: Rc<RefCell<FxHashSet<u64>>>,
61    /// Range slider active thumb - false=start, true=end.
62    range_active_thumb: Rc<RefCell<FxHashMap<u64, bool>>>,
63}
64
65/// Statistics about layout performance.
66#[derive(Clone, Debug, Default)]
67pub struct LayoutStats {
68    /// Stats from tree reconciliation.
69    pub tree: TreeStats,
70
71    /// Taffy nodes created this frame.
72    pub taffy_created: usize,
73
74    /// Taffy nodes reused this frame.
75    pub taffy_reused: usize,
76
77    /// Layout cache hits.
78    pub layout_hits: usize,
79
80    /// Layout cache misses.
81    pub layout_misses: usize,
82
83    /// Paint cache hits (repaint boundaries).
84    pub paint_cache_hits: usize,
85
86    /// Paint cache misses (repaint boundaries).
87    pub paint_cache_misses: usize,
88
89    /// Nodes skipped due to clip/viewport culling.
90    pub paint_culled: usize,
91
92    /// Total time for layout+paint (ms).
93    pub layout_time_ms: f32,
94}
95
96#[derive(Clone)]
97struct PaintCacheEntry {
98    subtree_hash: u64,
99    stamp: u64,
100    rect: repose_core::Rect,
101    sem_parent: Option<u64>,
102    alpha_q: u8,
103    nodes: Arc<Vec<SceneNode>>,
104    hits: Arc<Vec<HitRegion>>,
105    sems: Arc<Vec<SemNode>>,
106}
107
108/// Context stored with each Taffy node.
109#[derive(Clone)]
110enum NodeContext {
111    Text {
112        text: String,
113        font_dp: f32,
114        soft_wrap: bool,
115        max_lines: Option<usize>,
116        overflow: TextOverflow,
117    },
118    Button {
119        label: String,
120    },
121    TextField {
122        multiline: bool,
123    },
124    Checkbox,
125    Radio,
126    Switch,
127    Slider,
128    Range,
129    Progress,
130    Container,
131    ScrollContainer,
132}
133
134#[derive(Clone)]
135struct TextLayout {
136    lines: Vec<String>,
137    size_px: f32,
138    line_h_px: f32,
139}
140
141impl Default for LayoutEngine {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl LayoutEngine {
148    pub fn new() -> Self {
149        Self {
150            tree: ViewTree::new(),
151            taffy: TaffyTree::new(),
152            taffy_map: FxHashMap::default(),
153            reverse_map: FxHashMap::default(),
154            text_cache: FxHashMap::default(),
155            last_size_px: None,
156            layout_valid: false,
157            paint_cache: FxHashMap::default(),
158            stats: LayoutStats::default(),
159            last_locals_stamp: None,
160            view_ids: FxHashMap::default(),
161            next_view_id: 1,
162            slider_dragging: Rc::new(RefCell::new(FxHashSet::default())),
163            range_active_thumb: Rc::new(RefCell::new(FxHashMap::default())),
164        }
165    }
166
167    fn ensure_view_id(&mut self, node_id: NodeId) -> u64 {
168        if let Some(&id) = self.view_ids.get(&node_id) {
169            return id;
170        }
171        let id = self.next_view_id;
172        self.next_view_id += 1;
173        self.view_ids.insert(node_id, id);
174        id
175    }
176
177    fn locals_stamp() -> u64 {
178        let mut h = FxHasher::default();
179
180        // These affect layout measurement and/or flex direction decisions.
181        locals::density().scale.to_bits().hash(&mut h);
182        locals::text_scale().0.to_bits().hash(&mut h);
183
184        let dir_u8 = match locals::text_direction() {
185            locals::TextDirection::Ltr => 0u8,
186            locals::TextDirection::Rtl => 1u8,
187        };
188        dir_u8.hash(&mut h);
189
190        h.finish()
191    }
192
193    pub fn layout_frame(
194        &mut self,
195        root: &View,
196        size_px: (u32, u32),
197        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
198        interactions: &Interactions,
199        focused: Option<u64>,
200    ) -> (Scene, Vec<HitRegion>, Vec<SemNode>) {
201        let start = web_time::Instant::now();
202        repose_text::begin_frame();
203        self.stats = LayoutStats::default();
204
205        // 0. Check global invalidation
206        let locals_stamp = Self::locals_stamp();
207        let locals_changed = self.last_locals_stamp != Some(locals_stamp);
208        if locals_changed {
209            self.layout_valid = false;
210            self.paint_cache.clear();
211            self.text_cache.clear();
212        }
213
214        // 1. Update tree
215        let root_node_id = self.tree.update(root);
216        self.stats.tree = self.tree.stats.clone();
217
218        // 2. Determine layout need
219        let size_changed = self.last_size_px != Some(size_px);
220        let has_tree_mutation =
221            !self.tree.dirty_nodes().is_empty() || !self.tree.removed_ids.is_empty();
222        let need_layout = size_changed || !self.layout_valid || has_tree_mutation || locals_changed;
223
224        // Helpers
225        let px = |dp_val: f32| dp_to_px(dp_val);
226        let font_px = |dp_font: f32| dp_to_px(dp_font) * locals::text_scale().0;
227
228        // 3. Sync Taffy
229        self.sync_taffy_tree(root_node_id, &font_px);
230
231        // 4. Compute Layout
232        let taffy_root = self.taffy_map.get(&root_node_id).copied();
233        if let Some(taffy_root) = taffy_root {
234            if need_layout {
235                if let Ok(mut style) = self.taffy.style(taffy_root).cloned() {
236                    style.size.width = length(size_px.0 as f32);
237                    style.size.height = length(size_px.1 as f32);
238                    let _ = self.taffy.set_style(taffy_root, style);
239                }
240
241                let available = taffy::geometry::Size {
242                    width: AvailableSpace::Definite(size_px.0 as f32),
243                    height: AvailableSpace::Definite(size_px.1 as f32),
244                };
245
246                let text_cache = &mut self.text_cache;
247                let reverse_map = &self.reverse_map;
248                let tree = &self.tree;
249
250                let _ = self.taffy.compute_layout_with_measure(
251                    taffy_root,
252                    available,
253                    |known, avail, taffy_node, ctx, _style| {
254                        Self::measure_node(
255                            known,
256                            avail,
257                            taffy_node,
258                            ctx.as_deref(),
259                            text_cache,
260                            reverse_map,
261                            tree,
262                            &font_px,
263                            &px,
264                        )
265                    },
266                );
267
268                self.last_locals_stamp = Some(locals_stamp);
269
270                self.layout_valid = true;
271                self.last_size_px = Some(size_px);
272                self.stats.layout_misses += 1;
273            } else {
274                self.stats.layout_hits += 1;
275            }
276        }
277        self.stats.layout_time_ms = (web_time::Instant::now() - start).as_secs_f32() * 1000.0;
278
279        // 5. Paint
280        let (scene, hits, sems) = self.paint(
281            root_node_id,
282            textfield_states,
283            interactions,
284            focused,
285            &font_px,
286        );
287
288        self.tree.clear_dirty();
289        (scene, hits, sems)
290    }
291
292    fn sync_taffy_tree(&mut self, root_id: NodeId, font_px: &dyn Fn(f32) -> f32) {
293        // Removals
294        for &node_id in &self.tree.removed_ids {
295            if let Some(taffy_id) = self.taffy_map.remove(&node_id) {
296                let _ = self.taffy.remove(taffy_id);
297                self.reverse_map.remove(&taffy_id);
298                self.text_cache.remove(&node_id);
299                self.paint_cache.remove(&node_id);
300            }
301            // Clean up view_ids and drag state
302            if let Some(&vid) = self.view_ids.get(&node_id) {
303                self.slider_dragging.borrow_mut().remove(&vid);
304                self.range_active_thumb.borrow_mut().remove(&vid);
305            }
306            self.view_ids.remove(&node_id);
307        }
308
309        // Updates
310        let dirty_nodes: Vec<NodeId> = self.tree.dirty_nodes().iter().copied().collect();
311        for node_id in dirty_nodes {
312            self.update_taffy_node(node_id, font_px);
313        }
314
315        // Ensure root
316        if !self.taffy_map.contains_key(&root_id) {
317            self.update_taffy_node(root_id, font_px);
318        }
319    }
320
321    fn update_taffy_node(
322        &mut self,
323        node_id: NodeId,
324        font_px: &dyn Fn(f32) -> f32,
325    ) -> taffy::NodeId {
326        // Ensure this node has a stable view id
327        let _ = self.ensure_view_id(node_id);
328
329        if let Some(&t_id) = self.taffy_map.get(&node_id) {
330            self.apply_updates_to_taffy(node_id, t_id, font_px);
331            return t_id;
332        }
333
334        let (style, ctx, children) = {
335            let node = self.tree.get(node_id).expect("Node missing in update");
336            (
337                self.style_from_node(node, font_px),
338                self.context_from_node(node),
339                node.children.clone(),
340            )
341        };
342
343        let child_taffy_ids: Vec<taffy::NodeId> = children
344            .iter()
345            .map(|&child_id| self.update_taffy_node(child_id, font_px))
346            .collect();
347
348        let t_id = if child_taffy_ids.is_empty() {
349            self.taffy.new_leaf_with_context(style, ctx).unwrap()
350        } else {
351            let t = self
352                .taffy
353                .new_with_children(style, &child_taffy_ids)
354                .unwrap();
355            let _ = self.taffy.set_node_context(t, Some(ctx));
356            t
357        };
358
359        self.taffy_map.insert(node_id, t_id);
360        self.reverse_map.insert(t_id, node_id);
361        self.stats.taffy_created += 1;
362        t_id
363    }
364
365    fn apply_updates_to_taffy(
366        &mut self,
367        node_id: NodeId,
368        taffy_id: taffy::NodeId,
369        font_px: &dyn Fn(f32) -> f32,
370    ) {
371        // Ensure this node has a stable view id
372        let _ = self.ensure_view_id(node_id);
373
374        let (new_style, new_ctx, children) = {
375            let node = self.tree.get(node_id).unwrap();
376            (
377                self.style_from_node(node, font_px),
378                self.context_from_node(node),
379                node.children.clone(),
380            )
381        };
382
383        let _ = self.taffy.set_style(taffy_id, new_style);
384        let _ = self.taffy.set_node_context(taffy_id, Some(new_ctx));
385
386        let child_taffy_ids: Vec<taffy::NodeId> = children
387            .iter()
388            .map(|&child_id| self.update_taffy_node(child_id, font_px))
389            .collect();
390        let _ = self.taffy.set_children(taffy_id, &child_taffy_ids);
391        self.stats.taffy_reused += 1;
392    }
393
394    fn style_from_node(&self, node: &TreeNode, font_px: &dyn Fn(f32) -> f32) -> taffy::Style {
395        let m = &node.modifier;
396        let kind = &node.kind;
397        let px = |dp_val: f32| dp_to_px(dp_val);
398        let mut s = taffy::Style::default();
399
400        s.display = Display::Flex;
401        match kind {
402            ViewKind::Row => {
403                s.flex_direction = if locals::text_direction() == locals::TextDirection::Rtl {
404                    FlexDirection::RowReverse
405                } else {
406                    FlexDirection::Row
407                };
408            }
409            ViewKind::Column
410            | ViewKind::Surface
411            | ViewKind::ScrollV { .. }
412            | ViewKind::ScrollXY { .. }
413            | ViewKind::OverlayHost => {
414                s.flex_direction = FlexDirection::Column;
415            }
416            ViewKind::Stack => s.display = Display::Grid,
417            _ => {}
418        }
419
420        s.align_items = Some(AlignItems::Stretch);
421        s.justify_content = Some(JustifyContent::FlexStart);
422
423        if matches!(
424            kind,
425            ViewKind::Checkbox { .. }
426                | ViewKind::RadioButton { .. }
427                | ViewKind::Switch { .. }
428                | ViewKind::Slider { .. }
429                | ViewKind::RangeSlider { .. }
430                | ViewKind::Image { .. }
431        ) {
432            s.flex_shrink = 0.0;
433        } else {
434            s.flex_shrink = 1.0;
435        }
436
437        if let Some(g) = m.flex_grow {
438            s.flex_grow = g.max(0.0);
439        }
440        if let Some(sh) = m.flex_shrink {
441            s.flex_shrink = sh.max(0.0);
442        }
443        if let Some(b) = m.flex_basis {
444            s.flex_basis = length(px(b.max(0.0)));
445        }
446        if let Some(w) = m.flex_wrap {
447            s.flex_wrap = w;
448        }
449        if let Some(d) = m.flex_dir {
450            s.flex_direction = d;
451        }
452        if let Some(a) = m.align_self {
453            s.align_self = Some(a);
454        }
455        if let Some(j) = m.justify_content {
456            s.justify_content = Some(j);
457        }
458        if let Some(ai) = m.align_items_container {
459            s.align_items = Some(ai);
460        }
461        if let Some(ac) = m.align_content {
462            s.align_content = Some(ac);
463        }
464
465        if let Some(v) = m.margin_top {
466            s.margin.top = length(px(v));
467        }
468        if let Some(v) = m.margin_left {
469            s.margin.left = length(px(v));
470        }
471        if let Some(v) = m.margin_right {
472            s.margin.right = length(px(v));
473        }
474        if let Some(v) = m.margin_bottom {
475            s.margin.bottom = length(px(v));
476        }
477
478        if let Some(PositionType::Absolute) = m.position_type {
479            s.position = Position::Absolute;
480            s.inset = taffy::geometry::Rect {
481                left: m.offset_left.map(|v| length(px(v))).unwrap_or_else(auto),
482                right: m.offset_right.map(|v| length(px(v))).unwrap_or_else(auto),
483                top: m.offset_top.map(|v| length(px(v))).unwrap_or_else(auto),
484                bottom: m.offset_bottom.map(|v| length(px(v))).unwrap_or_else(auto),
485            };
486        }
487
488        if let Some(cfg) = &m.grid {
489            s.display = Display::Grid;
490            s.grid_template_columns = (0..cfg.columns.max(1))
491                .map(|_| GridTemplateComponent::Single(flex(1.0)))
492                .collect();
493            s.gap = taffy::geometry::Size {
494                width: length(px(cfg.column_gap)),
495                height: length(px(cfg.row_gap)),
496            };
497        }
498
499        if matches!(kind, ViewKind::ScrollV { .. } | ViewKind::ScrollXY { .. }) {
500            s.overflow = taffy::geometry::Point {
501                x: Overflow::Hidden,
502                y: Overflow::Hidden,
503            };
504        }
505
506        if let Some(pv) = m.padding_values {
507            s.padding = taffy::geometry::Rect {
508                left: length(px(pv.left)),
509                right: length(px(pv.right)),
510                top: length(px(pv.top)),
511                bottom: length(px(pv.bottom)),
512            };
513        } else if let Some(p) = m.padding {
514            let v = length(px(p));
515            s.padding = taffy::geometry::Rect {
516                left: v,
517                right: v,
518                top: v,
519                bottom: v,
520            };
521        }
522
523        let mut width_set = false;
524        let mut height_set = false;
525        if let Some(sz) = m.size {
526            if sz.width.is_finite() {
527                s.size.width = length(px(sz.width.max(0.0)));
528                width_set = true;
529            }
530            if sz.height.is_finite() {
531                s.size.height = length(px(sz.height.max(0.0)));
532                height_set = true;
533            }
534        }
535        if let Some(w) = m.width {
536            s.size.width = length(px(w.max(0.0)));
537            width_set = true;
538        }
539        if let Some(h) = m.height {
540            s.size.height = length(px(h.max(0.0)));
541            height_set = true;
542        }
543
544        if (m.fill_max || m.fill_max_w) && !width_set {
545            s.size.width = percent(1.0);
546            if s.min_size.width.is_auto() {
547                s.min_size.width = length(0.0);
548            }
549        }
550        if (m.fill_max || m.fill_max_h) && !height_set {
551            s.size.height = percent(1.0);
552            if matches!(kind, ViewKind::ScrollV { .. } | ViewKind::ScrollXY { .. })
553                && s.min_size.height.is_auto()
554            {
555                s.min_size.height = length(0.0);
556            }
557        }
558
559        if s.min_size.width.is_auto() {
560            s.min_size.width = length(0.0);
561        }
562
563        if matches!(kind, ViewKind::Button { .. }) {
564            s.display = Display::Flex;
565            s.flex_direction = if locals::text_direction() == locals::TextDirection::Rtl {
566                FlexDirection::RowReverse
567            } else {
568                FlexDirection::Row
569            };
570            if m.justify_content.is_none() {
571                s.justify_content = Some(JustifyContent::Center);
572            }
573            if m.align_items_container.is_none() {
574                s.align_items = Some(AlignItems::Center);
575            }
576            if m.padding.is_none() && m.padding_values.is_none() {
577                let ph = px(14.0);
578                let pv = px(10.0);
579                s.padding = taffy::geometry::Rect {
580                    left: length(ph),
581                    right: length(ph),
582                    top: length(pv),
583                    bottom: length(pv),
584                };
585            }
586            if m.min_height.is_none() && s.min_size.height.is_auto() {
587                s.min_size.height = length(px(40.0));
588            }
589        }
590
591        if let Some(v) = m.min_width {
592            s.min_size.width = length(px(v.max(0.0)));
593        }
594        if let Some(v) = m.min_height {
595            s.min_size.height = length(px(v.max(0.0)));
596        }
597        if let Some(v) = m.max_width {
598            s.max_size.width = length(px(v.max(0.0)));
599        }
600        if let Some(v) = m.max_height {
601            s.max_size.height = length(px(v.max(0.0)));
602        }
603        if let Some(r) = m.aspect_ratio {
604            s.aspect_ratio = Some(r.max(0.0));
605        }
606
607        if m.grid_col_span.is_some() || m.grid_row_span.is_some() {
608            let col_span = m.grid_col_span.unwrap_or(1).max(1);
609            let row_span = m.grid_row_span.unwrap_or(1).max(1);
610            s.grid_column = taffy::geometry::Line {
611                start: GridPlacement::Auto,
612                end: GridPlacement::Span(col_span),
613            };
614            s.grid_row = taffy::geometry::Line {
615                start: GridPlacement::Auto,
616                end: GridPlacement::Span(row_span),
617            };
618        }
619        s
620    }
621
622    fn context_from_node(&self, node: &TreeNode) -> NodeContext {
623        match &node.kind {
624            ViewKind::Text {
625                text,
626                font_size,
627                soft_wrap,
628                max_lines,
629                overflow,
630                ..
631            } => NodeContext::Text {
632                text: text.clone(),
633                font_dp: *font_size,
634                soft_wrap: *soft_wrap,
635                max_lines: *max_lines,
636                overflow: *overflow,
637            },
638            ViewKind::Button { .. } => NodeContext::Button {
639                label: String::new(),
640            },
641            ViewKind::TextField { multiline, .. } => NodeContext::TextField {
642                multiline: *multiline,
643            },
644            ViewKind::Checkbox { .. } => NodeContext::Checkbox,
645            ViewKind::RadioButton { .. } => NodeContext::Radio,
646            ViewKind::Switch { .. } => NodeContext::Switch,
647            ViewKind::Slider { .. } => NodeContext::Slider,
648            ViewKind::RangeSlider { .. } => NodeContext::Range,
649            ViewKind::ProgressBar { .. } => NodeContext::Progress,
650            ViewKind::ScrollV { .. } | ViewKind::ScrollXY { .. } => NodeContext::ScrollContainer,
651            ViewKind::OverlayHost => NodeContext::Container,
652            _ => NodeContext::Container,
653        }
654    }
655
656    fn measure_node(
657        known: taffy::geometry::Size<Option<f32>>,
658        avail: taffy::geometry::Size<AvailableSpace>,
659        taffy_node: taffy::NodeId,
660        ctx: Option<&NodeContext>,
661        text_cache: &mut FxHashMap<NodeId, TextLayout>,
662        reverse_map: &FxHashMap<taffy::NodeId, NodeId>,
663        _tree: &ViewTree,
664        font_px: &dyn Fn(f32) -> f32,
665        px: &dyn Fn(f32) -> f32,
666    ) -> taffy::geometry::Size<f32> {
667        match ctx {
668            Some(NodeContext::Text {
669                text,
670                font_dp,
671                soft_wrap,
672                max_lines,
673                overflow,
674            }) => {
675                let size_px_val = font_px(*font_dp);
676                let line_h_px_val = size_px_val * 1.3;
677                let max_content_w = measure_text(text, size_px_val)
678                    .positions
679                    .last()
680                    .copied()
681                    .unwrap_or(0.0)
682                    .max(0.0);
683
684                let mut min_content_w = 0.0f32;
685                for w in text.split_whitespace() {
686                    let ww = measure_text(w, size_px_val)
687                        .positions
688                        .last()
689                        .copied()
690                        .unwrap_or(0.0);
691                    min_content_w = min_content_w.max(ww);
692                }
693                if min_content_w <= 0.0 {
694                    min_content_w = max_content_w;
695                }
696
697                let wrap_w_px = if let Some(w) = known.width.filter(|w| *w > 0.5) {
698                    w
699                } else {
700                    match avail.width {
701                        AvailableSpace::Definite(w) if w > 0.5 => w,
702                        AvailableSpace::MinContent => min_content_w,
703                        AvailableSpace::MaxContent => max_content_w,
704                        _ => max_content_w,
705                    }
706                };
707
708                let lines = if *soft_wrap {
709                    repose_text::wrap_lines(text, size_px_val, wrap_w_px, *max_lines, true).0
710                } else if matches!(overflow, TextOverflow::Ellipsis)
711                    && max_content_w > wrap_w_px + 0.5
712                {
713                    vec![repose_text::ellipsize_line(text, size_px_val, wrap_w_px)]
714                } else {
715                    vec![text.clone()]
716                };
717
718                let max_line_w = lines
719                    .iter()
720                    .map(|line| {
721                        measure_text(line, size_px_val)
722                            .positions
723                            .last()
724                            .copied()
725                            .unwrap_or(0.0)
726                    })
727                    .fold(0.0f32, f32::max);
728
729                if let Some(node_id) = reverse_map.get(&taffy_node) {
730                    text_cache.insert(
731                        *node_id,
732                        TextLayout {
733                            lines: lines.clone(),
734                            size_px: size_px_val,
735                            line_h_px: line_h_px_val,
736                        },
737                    );
738                }
739                taffy::geometry::Size {
740                    width: max_line_w,
741                    height: line_h_px_val * lines.len().max(1) as f32,
742                }
743            }
744            Some(NodeContext::Button { label }) => taffy::geometry::Size {
745                width: (label.len() as f32 * font_px(16.0) * 0.6) + px(24.0),
746                height: px(36.0),
747            },
748            Some(NodeContext::TextField { multiline }) => taffy::geometry::Size {
749                width: known.width.unwrap_or(px(160.0)),
750                height: if *multiline {
751                    known.height.unwrap_or(px(140.0))
752                } else {
753                    px(36.0)
754                },
755            },
756            Some(NodeContext::Checkbox) => taffy::geometry::Size {
757                width: known.width.unwrap_or(px(24.0)),
758                height: px(24.0),
759            },
760            Some(NodeContext::Radio) => taffy::geometry::Size {
761                width: known.width.unwrap_or(px(18.0)),
762                height: px(18.0),
763            },
764            Some(NodeContext::Switch) => taffy::geometry::Size {
765                width: known.width.unwrap_or(px(46.0)),
766                height: px(28.0),
767            },
768            Some(NodeContext::Slider) => taffy::geometry::Size {
769                width: known.width.unwrap_or(px(200.0)),
770                height: px(28.0),
771            },
772            Some(NodeContext::Range) => taffy::geometry::Size {
773                width: known.width.unwrap_or(px(220.0)),
774                height: px(28.0),
775            },
776            Some(NodeContext::Progress) => taffy::geometry::Size {
777                width: known.width.unwrap_or(px(200.0)),
778                height: px(12.0),
779            },
780            _ => taffy::geometry::Size::ZERO,
781        }
782    }
783
784    fn paint(
785        &mut self,
786        root_id: NodeId,
787        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
788        interactions: &Interactions,
789        focused: Option<u64>,
790        font_px: &dyn Fn(f32) -> f32,
791    ) -> (Scene, Vec<HitRegion>, Vec<SemNode>) {
792        let mut scene = Scene {
793            clear_color: locals::theme().background,
794            nodes: vec![],
795        };
796        let mut hits = Vec::new();
797        let mut sems = Vec::new();
798        let mut deferred: Vec<(NodeId, (f32, f32), f32, Option<u64>, f32)> = Vec::new();
799        let mut deferred_blockers: Vec<(f32, repose_core::Rect)> = Vec::new();
800
801        self.walk_paint(
802            root_id,
803            &mut scene,
804            &mut hits,
805            &mut sems,
806            textfield_states,
807            interactions,
808            focused,
809            (0.0, 0.0),
810            1.0,
811            None,
812            font_px,
813            true,
814            &mut deferred,
815            false, // Allow deferral in first pass
816        );
817
818        // Paint deferred nodes sorted by render_z_index (ascending = higher on top)
819        deferred.sort_by(|a, b| a.4.partial_cmp(&b.4).unwrap_or(Ordering::Equal));
820        for (node_id, parent_offset_px, alpha_accum, sem_parent, z) in deferred.iter().copied() {
821            let view_id = *self.view_ids.get(&node_id).unwrap_or(&0);
822            let taffy_id = self.taffy_map[&node_id];
823            let layout = self.taffy.layout(taffy_id).unwrap();
824            let rect = repose_core::Rect {
825                x: parent_offset_px.0 + layout.location.x,
826                y: parent_offset_px.1 + layout.location.y,
827                w: layout.size.width,
828                h: layout.size.height,
829            };
830            if let Some(node) = self.tree.get(node_id) {
831                if node.modifier.input_blocker && !node.modifier.hit_passthrough {
832                    deferred_blockers.push((z, rect));
833                }
834            }
835            self.walk_paint(
836                node_id,
837                &mut scene,
838                &mut hits,
839                &mut sems,
840                textfield_states,
841                interactions,
842                focused,
843                parent_offset_px,
844                alpha_accum,
845                sem_parent,
846                font_px,
847                true,
848                &mut Vec::new(), // No further deferral in second pass
849                true,            // Skip defer check
850            );
851            let _ = view_id;
852        }
853        deferred.clear();
854
855        if !deferred_blockers.is_empty() {
856            deferred_blockers.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
857            let max_z = hits
858                .iter()
859                .map(|h| h.z_index)
860                .fold(0.0_f32, |a, b| a.max(b));
861            let bump = max_z + 1.0;
862            for (i, (_z, rect)) in deferred_blockers.iter().enumerate() {
863                let blocker_id = u64::MAX - i as u64;
864                hits.push(HitRegion {
865                    id: blocker_id,
866                    rect: *rect,
867                    on_click: None,
868                    on_scroll: None,
869                    focusable: false,
870                    on_pointer_down: None,
871                    on_pointer_move: None,
872                    on_pointer_up: None,
873                    on_pointer_enter: None,
874                    on_pointer_leave: None,
875                    z_index: bump + i as f32,
876                    on_text_change: None,
877                    on_text_submit: None,
878                    tf_state_key: None,
879                    tf_multiline: false,
880                    on_action: None,
881                    cursor: None,
882                    on_drag_start: None,
883                    on_drag_end: None,
884                    on_drag_enter: None,
885                    on_drag_over: None,
886                    on_drag_leave: None,
887                    on_drop: None,
888                });
889            }
890        }
891
892        hits.sort_by(|a, b| a.z_index.partial_cmp(&b.z_index).unwrap_or(Ordering::Equal));
893        (scene, hits, sems)
894    }
895
896    fn paint_stamp_hash(
897        &self,
898        root: NodeId,
899        interactions: &Interactions,
900        focused: Option<u64>,
901        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
902        sem_parent: Option<u64>,
903        alpha_accum: f32,
904    ) -> u64 {
905        let mut h = FxHasher::default();
906        sem_parent.hash(&mut h);
907        let alpha_q: u8 = (alpha_accum.clamp(0.0, 1.0) * 255.0).round() as u8;
908        alpha_q.hash(&mut h);
909        interactions.hover.hash(&mut h);
910        focused.hash(&mut h);
911        if !interactions.pressed.is_empty() {
912            let mut pressed: Vec<u64> = interactions.pressed.iter().copied().collect();
913            pressed.sort_unstable();
914            pressed.hash(&mut h);
915        }
916
917        let mut stack = Vec::new();
918        stack.push(root);
919        while let Some(id) = stack.pop() {
920            let Some(n) = self.tree.get(id) else { continue };
921            match &n.kind {
922                ViewKind::ScrollV {
923                    get_scroll_offset, ..
924                } => {
925                    if let Some(get) = get_scroll_offset {
926                        let q = (get() * 8.0) as i32;
927                        q.hash(&mut h);
928                    }
929                }
930                ViewKind::ScrollXY {
931                    get_scroll_offset_xy,
932                    ..
933                } => {
934                    if let Some(get) = get_scroll_offset_xy {
935                        let (x, y) = get();
936                        ((x * 8.0) as i32).hash(&mut h);
937                        ((y * 8.0) as i32).hash(&mut h);
938                    }
939                }
940                ViewKind::TextField { state_key, .. } => {
941                    let vid = *self.view_ids.get(&id).unwrap_or(&0);
942                    let tf_key = if *state_key != 0 { *state_key } else { vid };
943                    if let Some(st_rc) = textfield_states.get(&tf_key) {
944                        let st = st_rc.borrow();
945                        let mut th = FxHasher::default();
946                        st.text.hash(&mut th);
947                        th.finish().hash(&mut h);
948                        st.selection.start.hash(&mut h);
949                        st.selection.end.hash(&mut h);
950                        if let Some(r) = &st.composition {
951                            r.start.hash(&mut h);
952                            r.end.hash(&mut h);
953                        } else {
954                            0usize.hash(&mut h);
955                            0usize.hash(&mut h);
956                        }
957                        ((st.scroll_offset * 8.0) as i32).hash(&mut h);
958                        ((st.scroll_offset_y * 8.0) as i32).hash(&mut h);
959                        st.caret_visible().hash(&mut h);
960                    }
961                }
962                _ => {}
963            }
964            for &ch in n.children.iter() {
965                stack.push(ch);
966            }
967        }
968        h.finish()
969    }
970
971    fn walk_paint_view(
972        &mut self,
973        view: &View,
974        scene: &mut Scene,
975        hits: &mut Vec<HitRegion>,
976        sems: &mut Vec<SemNode>,
977        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
978        interactions: &Interactions,
979        focused: Option<u64>,
980        parent_offset_px: (f32, f32),
981        alpha_accum: f32,
982        sem_parent: Option<u64>,
983        font_px: &dyn Fn(f32) -> f32,
984    ) {
985        let root_id = self.tree.update(view);
986        self.sync_taffy_tree(root_id, font_px);
987        self.walk_paint(
988            root_id,
989            scene,
990            hits,
991            sems,
992            textfield_states,
993            interactions,
994            focused,
995            parent_offset_px,
996            alpha_accum,
997            sem_parent,
998            font_px,
999            false,
1000            &mut Vec::new(),
1001            false,
1002        );
1003    }
1004
1005    fn walk_paint(
1006        &mut self,
1007        node_id: NodeId,
1008        scene: &mut Scene,
1009        hits: &mut Vec<HitRegion>,
1010        sems: &mut Vec<SemNode>,
1011        textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
1012        interactions: &Interactions,
1013        focused: Option<u64>,
1014        parent_offset_px: (f32, f32),
1015        alpha_accum: f32,
1016        sem_parent: Option<u64>,
1017        font_px: &dyn Fn(f32) -> f32,
1018        allow_cache: bool,
1019        deferred: &mut Vec<(NodeId, (f32, f32), f32, Option<u64>, f32)>,
1020        skip_defer: bool,
1021    ) {
1022        let (subtree_hash, modifier, kind, children) = {
1023            let n = self.tree.get(node_id).unwrap();
1024            (
1025                n.subtree_hash,
1026                n.modifier.clone(),
1027                n.kind.clone(),
1028                n.children.clone(),
1029            )
1030        };
1031
1032        let view_id = *self.view_ids.get(&node_id).unwrap_or(&0);
1033
1034        // Check if this node should be deferred for later painting
1035        if !skip_defer {
1036            if let Some(render_z) = modifier.render_z_index {
1037                if !deferred.is_empty() || render_z != 0.0 {
1038                    // Defer this node - it will be painted later based on render_z_index
1039                    deferred.push((node_id, parent_offset_px, alpha_accum, sem_parent, render_z));
1040                    return;
1041                }
1042            }
1043        }
1044        debug_assert!(view_id != 0);
1045
1046        let taffy_id = self.taffy_map[&node_id];
1047        let layout = self.taffy.layout(taffy_id).unwrap();
1048
1049        let local_rect = repose_core::Rect {
1050            x: layout.location.x,
1051            y: layout.location.y,
1052            w: layout.size.width,
1053            h: layout.size.height,
1054        };
1055        let rect = repose_core::Rect {
1056            x: parent_offset_px.0 + local_rect.x,
1057            y: parent_offset_px.1 + local_rect.y,
1058            w: local_rect.w,
1059            h: local_rect.h,
1060        };
1061
1062        let content_rect = if let Some(pv) = modifier.padding_values {
1063            repose_core::Rect {
1064                x: rect.x + dp_to_px(pv.left),
1065                y: rect.y + dp_to_px(pv.top),
1066                w: (rect.w - dp_to_px(pv.left) - dp_to_px(pv.right)).max(0.0),
1067                h: (rect.h - dp_to_px(pv.top) - dp_to_px(pv.bottom)).max(0.0),
1068            }
1069        } else if let Some(p) = modifier.padding {
1070            let p_px = dp_to_px(p);
1071            repose_core::Rect {
1072                x: rect.x + p_px,
1073                y: rect.y + p_px,
1074                w: (rect.w - 2.0 * p_px).max(0.0),
1075                h: (rect.h - 2.0 * p_px).max(0.0),
1076            }
1077        } else {
1078            rect
1079        };
1080
1081        let base_px = (rect.x, rect.y);
1082
1083        let is_hovered = interactions.hover == Some(view_id);
1084        let is_pressed = interactions.pressed.contains(&view_id);
1085        let is_focused = focused == Some(view_id);
1086        let this_alpha = modifier.alpha.unwrap_or(1.0);
1087        let alpha_accum = (alpha_accum * this_alpha).clamp(0.0, 1.0);
1088        let alpha_q: u8 = (alpha_accum * 255.0).round() as u8;
1089
1090        // Repaint Boundary
1091        if allow_cache && modifier.repaint_boundary {
1092            let stamp = self.paint_stamp_hash(
1093                node_id,
1094                interactions,
1095                focused,
1096                textfield_states,
1097                sem_parent,
1098                alpha_accum,
1099            );
1100            if let Some(entry) = self.paint_cache.get(&node_id) {
1101                if entry.subtree_hash == subtree_hash
1102                    && entry.stamp == stamp
1103                    && entry.rect == rect
1104                    && entry.sem_parent == sem_parent
1105                    && entry.alpha_q == alpha_q
1106                {
1107                    self.stats.paint_cache_hits += 1;
1108                    scene.nodes.extend(entry.nodes.iter().cloned());
1109                    hits.extend(entry.hits.iter().cloned());
1110                    sems.extend(entry.sems.iter().cloned());
1111                    return;
1112                }
1113            }
1114            self.stats.paint_cache_misses += 1;
1115            let mut local_scene = Scene {
1116                clear_color: scene.clear_color,
1117                nodes: Vec::new(),
1118            };
1119            let mut local_hits = Vec::new();
1120            let mut local_sems = Vec::new();
1121            self.walk_paint(
1122                node_id,
1123                &mut local_scene,
1124                &mut local_hits,
1125                &mut local_sems,
1126                textfield_states,
1127                interactions,
1128                focused,
1129                parent_offset_px,
1130                alpha_accum / this_alpha.max(1e-6),
1131                sem_parent,
1132                font_px,
1133                false,
1134                &mut Vec::new(), // Don't defer within repaint boundary
1135                true,            // Skip defer check in repaint boundary
1136            );
1137
1138            let entry = PaintCacheEntry {
1139                subtree_hash,
1140                stamp,
1141                rect,
1142                sem_parent,
1143                alpha_q,
1144                nodes: Arc::new(local_scene.nodes.clone()),
1145                hits: Arc::new(local_hits.clone()),
1146                sems: Arc::new(local_sems.clone()),
1147            };
1148            self.paint_cache.insert(node_id, entry);
1149            scene.nodes.extend(local_scene.nodes);
1150            hits.extend(local_hits);
1151            sems.extend(local_sems);
1152            return;
1153        }
1154
1155        let round_clip_px = clamp_radius(
1156            modifier.clip_rounded.map(dp_to_px).unwrap_or(0.0),
1157            rect.w,
1158            rect.h,
1159        );
1160        let push_round_clip = round_clip_px > 0.5 && rect.w > 0.5 && rect.h > 0.5;
1161        if (this_alpha - 1.0).abs() > 1e-6 {}
1162        if let Some(tf) = modifier.transform {
1163            scene.nodes.push(SceneNode::PushTransform { transform: tf });
1164        }
1165        if push_round_clip {
1166            scene.nodes.push(SceneNode::PushClip {
1167                rect,
1168                radius: round_clip_px,
1169            });
1170        }
1171
1172        if let Some(bg) = modifier.background {
1173            scene.nodes.push(SceneNode::Rect {
1174                rect,
1175                brush: mul_alpha_brush(bg, alpha_accum),
1176                radius: round_clip_px,
1177            });
1178        }
1179        if let Some(b) = &modifier.border {
1180            scene.nodes.push(SceneNode::Border {
1181                rect,
1182                color: mul_alpha_color(b.color, alpha_accum),
1183                width: dp_to_px(b.width),
1184                radius: clamp_radius(
1185                    dp_to_px(b.radius.max(modifier.clip_rounded.unwrap_or(0.0))),
1186                    rect.w,
1187                    rect.h,
1188                ),
1189            });
1190        }
1191        if let Some(p) = &modifier.painter {
1192            (p)(scene, rect);
1193        }
1194
1195        let has_pointer = modifier.on_pointer_down.is_some()
1196            || modifier.on_pointer_move.is_some()
1197            || modifier.on_pointer_up.is_some()
1198            || modifier.on_pointer_enter.is_some()
1199            || modifier.on_pointer_leave.is_some();
1200
1201        let has_dnd = modifier.on_drag_start.is_some()
1202            || modifier.on_drag_end.is_some()
1203            || modifier.on_drag_enter.is_some()
1204            || modifier.on_drag_over.is_some()
1205            || modifier.on_drag_leave.is_some()
1206            || modifier.on_drop.is_some();
1207
1208        let kind_handles_hit = matches!(
1209            kind,
1210            ViewKind::Button { .. }
1211                | ViewKind::TextField { .. }
1212                | ViewKind::Checkbox { .. }
1213                | ViewKind::RadioButton { .. }
1214                | ViewKind::Switch { .. }
1215                | ViewKind::Slider { .. }
1216                | ViewKind::RangeSlider { .. }
1217                | ViewKind::ScrollV { .. }
1218                | ViewKind::ScrollXY { .. }
1219        );
1220
1221        let needs_hit = has_pointer || modifier.click || has_dnd || modifier.on_action.is_some();
1222
1223        if needs_hit && !kind_handles_hit && !modifier.hit_passthrough {
1224            hits.push(HitRegion {
1225                id: view_id,
1226                rect,
1227                on_click: None,
1228                on_scroll: None,
1229                focusable: false,
1230                on_pointer_down: modifier.on_pointer_down.clone(),
1231                on_pointer_move: modifier.on_pointer_move.clone(),
1232                on_pointer_up: modifier.on_pointer_up.clone(),
1233                on_pointer_enter: modifier.on_pointer_enter.clone(),
1234                on_pointer_leave: modifier.on_pointer_leave.clone(),
1235                z_index: modifier.z_index,
1236                on_text_change: None,
1237                on_text_submit: None,
1238                tf_state_key: None,
1239                tf_multiline: false,
1240                on_action: modifier.on_action.clone(),
1241                cursor: modifier.cursor,
1242
1243                on_drag_start: modifier.on_drag_start.clone(),
1244                on_drag_end: modifier.on_drag_end.clone(),
1245                on_drag_enter: modifier.on_drag_enter.clone(),
1246                on_drag_over: modifier.on_drag_over.clone(),
1247                on_drag_leave: modifier.on_drag_leave.clone(),
1248                on_drop: modifier.on_drop.clone(),
1249            });
1250        }
1251
1252        let mut next_sem_parent = sem_parent;
1253
1254        match &kind {
1255            ViewKind::Text {
1256                text,
1257                color,
1258                font_size,
1259                soft_wrap,
1260                overflow,
1261                ..
1262            } => {
1263                let tl = self.text_cache.get(&node_id);
1264                let (size_px, line_h_px, lines) = if let Some(tl) = tl {
1265                    (tl.size_px, tl.line_h_px, tl.lines.clone())
1266                } else {
1267                    let px = font_px(*font_size);
1268                    (px, px * 1.3, vec![text.clone()])
1269                };
1270                let total_h = lines.len() as f32 * line_h_px;
1271                let need_v_clip =
1272                    total_h > content_rect.h + 0.5 && *overflow != TextOverflow::Visible;
1273                if lines.len() > 1 && !*soft_wrap { /* single line center handled elsewhere */ }
1274
1275                let need_clip =
1276                    *overflow != TextOverflow::Visible && (need_v_clip || content_rect.w > 0.0);
1277                if need_clip {
1278                    scene.nodes.push(SceneNode::PushClip {
1279                        rect: content_rect,
1280                        radius: 0.0,
1281                    });
1282                }
1283                for (i, ln) in lines.iter().enumerate() {
1284                    scene.nodes.push(SceneNode::Text {
1285                        rect: repose_core::Rect {
1286                            x: content_rect.x,
1287                            y: content_rect.y + i as f32 * line_h_px,
1288                            w: content_rect.w,
1289                            h: line_h_px,
1290                        },
1291                        text: Arc::<str>::from(ln.clone()),
1292                        color: mul_alpha_color(*color, alpha_accum),
1293                        size: size_px,
1294                    });
1295                }
1296                if need_clip {
1297                    scene.nodes.push(SceneNode::PopClip);
1298                }
1299                sems.push(SemNode {
1300                    id: view_id,
1301                    parent: sem_parent,
1302                    role: Role::Text,
1303                    label: Some(text.clone()),
1304                    rect,
1305                    focused: is_focused,
1306                    enabled: true,
1307                });
1308                next_sem_parent = Some(view_id);
1309            }
1310            ViewKind::Button { on_click } => {
1311                if modifier.background.is_none() {
1312                    let th = locals::theme();
1313                    let base = if is_pressed {
1314                        th.button_bg_pressed
1315                    } else if is_hovered {
1316                        th.button_bg_hover
1317                    } else {
1318                        th.button_bg
1319                    };
1320                    scene.nodes.push(SceneNode::Rect {
1321                        rect,
1322                        brush: Brush::Solid(base),
1323                        radius: modifier.clip_rounded.map(dp_to_px).unwrap_or(dp_to_px(6.0)),
1324                    });
1325                }
1326                if (modifier.click || on_click.is_some()) && !modifier.hit_passthrough {
1327                    hits.push(HitRegion {
1328                        id: view_id,
1329                        rect,
1330                        on_click: on_click.clone(),
1331                        on_scroll: None,
1332                        focusable: true,
1333                        on_pointer_down: modifier.on_pointer_down.clone(),
1334                        on_pointer_move: modifier.on_pointer_move.clone(),
1335                        on_pointer_up: modifier.on_pointer_up.clone(),
1336                        on_pointer_enter: modifier.on_pointer_enter.clone(),
1337                        on_pointer_leave: modifier.on_pointer_leave.clone(),
1338                        z_index: modifier.z_index,
1339                        on_text_change: None,
1340                        on_text_submit: None,
1341                        tf_state_key: None,
1342                        tf_multiline: false,
1343                        on_action: modifier.on_action.clone(),
1344                        cursor: modifier.cursor,
1345
1346                        on_drag_start: modifier.on_drag_start.clone(),
1347                        on_drag_end: modifier.on_drag_end.clone(),
1348                        on_drag_enter: modifier.on_drag_enter.clone(),
1349                        on_drag_over: modifier.on_drag_over.clone(),
1350                        on_drag_leave: modifier.on_drag_leave.clone(),
1351                        on_drop: modifier.on_drop.clone(),
1352                    });
1353                }
1354                sems.push(SemNode {
1355                    id: view_id,
1356                    parent: sem_parent,
1357                    role: Role::Button,
1358                    label: infer_label(&self.tree, node_id),
1359                    rect,
1360                    focused: is_focused,
1361                    enabled: true,
1362                });
1363                next_sem_parent = Some(view_id);
1364                if is_focused {
1365                    scene.nodes.push(SceneNode::Border {
1366                        rect,
1367                        color: locals::theme().focus,
1368                        width: dp_to_px(2.0),
1369                        radius: modifier.clip_rounded.map(dp_to_px).unwrap_or(dp_to_px(6.0)),
1370                    });
1371                }
1372            }
1373            ViewKind::Image { handle, tint, fit } => {
1374                scene.nodes.push(SceneNode::Image {
1375                    rect,
1376                    handle: *handle,
1377                    tint: mul_alpha_color(*tint, alpha_accum),
1378                    fit: *fit,
1379                });
1380            }
1381            ViewKind::TextField {
1382                state_key,
1383                hint,
1384                multiline,
1385                on_change,
1386                on_submit,
1387                ..
1388            } => {
1389                let tf_key = if *state_key != 0 { *state_key } else { view_id };
1390
1391                let pad_x = dp_to_px(TF_PADDING_X_DP);
1392                let inner = repose_core::Rect {
1393                    x: rect.x + pad_x,
1394                    y: rect.y + dp_to_px(8.0),
1395                    w: rect.w - 2.0 * pad_x,
1396                    h: rect.h - dp_to_px(16.0),
1397                };
1398
1399                // Scroll wheel support for multiline text areas
1400                let on_scroll = if *multiline {
1401                    let key = tf_key;
1402                    let h = inner.h;
1403                    let font_val = font_px(TF_FONT_DP);
1404                    let wrap_w = inner.w.max(1.0);
1405                    let states = textfield_states.get(&key).cloned();
1406                    Some(Rc::new(move |d: Vec2| -> Vec2 {
1407                        let Some(st_rc) = states.as_ref() else {
1408                            return d;
1409                        };
1410                        let mut st = st_rc.borrow_mut();
1411                        st.set_inner_height(h);
1412                        let layout = crate::textfield::layout_text_area(&st.text, font_val, wrap_w);
1413                        let content_h = layout.ranges.len().max(1) as f32 * layout.line_h_px;
1414                        let max_y = (content_h - st.inner_height).max(0.0);
1415
1416                        let before = st.scroll_offset_y;
1417                        let target = (st.scroll_offset_y - d.y).clamp(0.0, max_y);
1418                        st.scroll_offset_y = target;
1419
1420                        let consumed = before - target;
1421                        Vec2 {
1422                            x: d.x,
1423                            y: d.y - consumed,
1424                        }
1425                    }) as Rc<dyn Fn(Vec2) -> Vec2>)
1426                } else {
1427                    None
1428                };
1429
1430                if !modifier.hit_passthrough {
1431                    hits.push(HitRegion {
1432                        id: view_id,
1433                        rect,
1434                        on_click: None,
1435                        on_scroll,
1436                        focusable: true,
1437                        on_pointer_down: None,
1438                        on_pointer_move: None,
1439                        on_pointer_up: None,
1440                        on_pointer_enter: None,
1441                        on_pointer_leave: None,
1442                        z_index: modifier.z_index,
1443                        on_text_change: on_change.clone(),
1444                        on_text_submit: on_submit.clone(),
1445                        tf_state_key: Some(tf_key),
1446                        tf_multiline: *multiline,
1447                        on_action: modifier.on_action.clone(),
1448                        cursor: Some(crate::CursorIcon::Text),
1449                        on_drag_start: modifier.on_drag_start.clone(),
1450                        on_drag_end: modifier.on_drag_end.clone(),
1451                        on_drag_enter: modifier.on_drag_enter.clone(),
1452                        on_drag_over: modifier.on_drag_over.clone(),
1453                        on_drag_leave: modifier.on_drag_leave.clone(),
1454                        on_drop: modifier.on_drop.clone(),
1455                    });
1456                }
1457
1458                scene.nodes.push(SceneNode::PushClip {
1459                    rect: inner,
1460                    radius: 0.0,
1461                });
1462
1463                if is_focused {
1464                    scene.nodes.push(SceneNode::Border {
1465                        rect,
1466                        color: locals::theme().focus,
1467                        width: dp_to_px(2.0),
1468                        radius: modifier.clip_rounded.map(dp_to_px).unwrap_or(dp_to_px(6.0)),
1469                    });
1470                }
1471
1472                if let Some(state_rc) = textfield_states.get(&tf_key) {
1473                    {
1474                        let mut st = state_rc.borrow_mut();
1475                        st.set_inner_width(inner.w);
1476                        st.set_inner_height(inner.h);
1477                    }
1478
1479                    let st = state_rc.borrow();
1480                    let font_val = font_px(TF_FONT_DP);
1481
1482                    if !*multiline {
1483                        let m = measure_text(&st.text, font_val);
1484
1485                        if st.selection.start != st.selection.end {
1486                            let sx = m
1487                                .positions
1488                                .get(byte_to_char_index(&m, st.selection.start))
1489                                .copied()
1490                                .unwrap_or(0.0)
1491                                - st.scroll_offset;
1492                            let ex = m
1493                                .positions
1494                                .get(byte_to_char_index(&m, st.selection.end))
1495                                .copied()
1496                                .unwrap_or(sx)
1497                                - st.scroll_offset;
1498
1499                            let th = locals::theme();
1500                            let selection = mul_alpha_color(th.focus, 85.0 / 255.0);
1501                            scene.nodes.push(SceneNode::Rect {
1502                                rect: repose_core::Rect {
1503                                    x: inner.x + sx.max(0.0),
1504                                    y: inner.y,
1505                                    w: (ex - sx).max(0.0),
1506                                    h: inner.h,
1507                                },
1508                                brush: Brush::Solid(selection),
1509                                radius: 0.0,
1510                            });
1511                        }
1512
1513                        let th = locals::theme();
1514                        let txt_col = if st.text.is_empty() {
1515                            th.on_surface_variant
1516                        } else {
1517                            th.on_surface
1518                        };
1519
1520                        scene.nodes.push(SceneNode::Text {
1521                            rect: repose_core::Rect {
1522                                x: inner.x - st.scroll_offset,
1523                                y: inner.y,
1524                                w: inner.w,
1525                                h: inner.h,
1526                            },
1527                            text: Arc::from(if st.text.is_empty() {
1528                                hint.clone()
1529                            } else {
1530                                st.text.clone()
1531                            }),
1532                            color: txt_col,
1533                            size: font_val,
1534                        });
1535
1536                        if st.selection.start == st.selection.end && st.caret_visible() {
1537                            let cx = m
1538                                .positions
1539                                .get(byte_to_char_index(&m, st.selection.end))
1540                                .copied()
1541                                .unwrap_or(0.0)
1542                                - st.scroll_offset;
1543                            scene.nodes.push(SceneNode::Rect {
1544                                rect: repose_core::Rect {
1545                                    x: inner.x + cx.max(0.0),
1546                                    y: inner.y,
1547                                    w: dp_to_px(1.0),
1548                                    h: inner.h,
1549                                },
1550                                brush: Brush::Solid(th.on_surface),
1551                                radius: 0.0,
1552                            });
1553                        }
1554                    } else {
1555                        let layout = crate::textfield::layout_text_area(
1556                            &st.text,
1557                            font_val,
1558                            inner.w.max(1.0),
1559                        );
1560                        let line_h = layout.line_h_px;
1561                        let content_h = layout.ranges.len().max(1) as f32 * line_h;
1562                        drop(st);
1563
1564                        // Reborrow mutably to clamp scroll after we know content height
1565                        {
1566                            let mut st = state_rc.borrow_mut();
1567                            st.clamp_scroll(content_h);
1568                        }
1569
1570                        // Render
1571                        let st = state_rc.borrow();
1572                        let th = locals::theme();
1573                        let selection = mul_alpha_color(th.focus, 85.0 / 255.0);
1574                        if st.text.is_empty() {
1575                            scene.nodes.push(SceneNode::Text {
1576                                rect: repose_core::Rect {
1577                                    x: inner.x,
1578                                    y: inner.y,
1579                                    w: inner.w,
1580                                    h: inner.h,
1581                                },
1582                                text: Arc::from(hint.clone()),
1583                                color: th.on_surface_variant,
1584                                size: font_val,
1585                            });
1586                        } else {
1587                            for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1588                                let ln = &st.text[s..e];
1589                                let draw_y = inner.y + (i as f32) * line_h - st.scroll_offset_y;
1590                                if draw_y + line_h < inner.y - 1.0
1591                                    || draw_y > inner.y + inner.h + 1.0
1592                                {
1593                                    continue;
1594                                }
1595                                scene.nodes.push(SceneNode::Text {
1596                                    rect: repose_core::Rect {
1597                                        x: inner.x,
1598                                        y: draw_y,
1599                                        w: inner.w,
1600                                        h: line_h,
1601                                    },
1602                                    text: Arc::<str>::from(ln.to_string()),
1603                                    color: locals::theme().on_surface,
1604                                    size: font_val,
1605                                });
1606                            }
1607                        }
1608
1609                        // Selection (multi-line)
1610                        if st.selection.start != st.selection.end {
1611                            let (sel_a, sel_b) = if st.selection.start < st.selection.end {
1612                                (st.selection.start, st.selection.end)
1613                            } else {
1614                                (st.selection.end, st.selection.start)
1615                            };
1616                            for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1617                                let os = sel_a.max(s);
1618                                let oe = sel_b.min(e);
1619                                if os >= oe {
1620                                    continue;
1621                                }
1622                                let ln = &st.text[s..e];
1623                                let m = measure_text(ln, font_val);
1624
1625                                let ls = os - s;
1626                                let le = oe - s;
1627
1628                                let sx = m
1629                                    .positions
1630                                    .get(byte_to_char_index(&m, ls))
1631                                    .copied()
1632                                    .unwrap_or(0.0);
1633                                let ex = m
1634                                    .positions
1635                                    .get(byte_to_char_index(&m, le))
1636                                    .copied()
1637                                    .unwrap_or(sx);
1638
1639                                let draw_y = inner.y + (i as f32) * line_h - st.scroll_offset_y;
1640                                scene.nodes.push(SceneNode::Rect {
1641                                    rect: repose_core::Rect {
1642                                        x: inner.x + sx,
1643                                        y: draw_y,
1644                                        w: (ex - sx).max(0.0),
1645                                        h: line_h,
1646                                    },
1647                                    brush: Brush::Solid(selection),
1648                                    radius: 0.0,
1649                                });
1650                            }
1651                        }
1652
1653                        // Caret (multi-line)
1654                        if st.selection.start == st.selection.end && st.caret_visible() {
1655                            let caret = st.selection.end.min(st.text.len());
1656                            let (cx, cy, _li) = crate::textfield::caret_xy_for_byte(
1657                                &st.text,
1658                                font_val,
1659                                inner.w.max(1.0),
1660                                caret,
1661                            );
1662                            let draw_x = inner.x + cx;
1663                            let draw_y = inner.y + cy - st.scroll_offset_y;
1664                            scene.nodes.push(SceneNode::Rect {
1665                                rect: repose_core::Rect {
1666                                    x: draw_x,
1667                                    y: draw_y,
1668                                    w: dp_to_px(1.0),
1669                                    h: line_h,
1670                                },
1671                                brush: Brush::Solid(th.on_surface),
1672                                radius: 0.0,
1673                            });
1674                        }
1675                    }
1676                } else {
1677                    let th = locals::theme();
1678                    scene.nodes.push(SceneNode::Text {
1679                        rect: inner,
1680                        text: Arc::from(hint.clone()),
1681                        color: th.on_surface_variant,
1682                        size: font_px(TF_FONT_DP),
1683                    });
1684                }
1685
1686                scene.nodes.push(SceneNode::PopClip);
1687
1688                sems.push(SemNode {
1689                    id: view_id,
1690                    parent: sem_parent,
1691                    role: Role::TextField,
1692                    label: Some(hint.clone()),
1693                    rect,
1694                    focused: is_focused,
1695                    enabled: true,
1696                });
1697                next_sem_parent = Some(view_id);
1698            }
1699            ViewKind::Checkbox { checked, on_change } => {
1700                let th = locals::theme();
1701                let sz = dp_to_px(18.0);
1702                let bx = rect.x;
1703                let by = rect.y + (rect.h - sz) * 0.5;
1704                scene.nodes.push(SceneNode::Rect {
1705                    rect: repose_core::Rect {
1706                        x: bx,
1707                        y: by,
1708                        w: sz,
1709                        h: sz,
1710                    },
1711                    brush: Brush::Solid(if *checked { th.primary } else { th.surface }),
1712                    radius: dp_to_px(3.0),
1713                });
1714                scene.nodes.push(SceneNode::Border {
1715                    rect: repose_core::Rect {
1716                        x: bx,
1717                        y: by,
1718                        w: sz,
1719                        h: sz,
1720                    },
1721                    color: th.outline,
1722                    width: dp_to_px(1.0),
1723                    radius: dp_to_px(3.0),
1724                });
1725                if *checked {
1726                    scene.nodes.push(SceneNode::Text {
1727                        rect: repose_core::Rect {
1728                            x: bx + dp_to_px(3.0),
1729                            y: rect.y + rect.h * 0.5 - font_px(16.0) * 0.6,
1730                            w: sz,
1731                            h: font_px(16.0),
1732                        },
1733                        text: Arc::from("✓"),
1734                        color: th.on_primary,
1735                        size: font_px(16.0),
1736                    });
1737                }
1738                let toggled = !*checked;
1739                let on_click = on_change.as_ref().map(|cb| {
1740                    let cb = cb.clone();
1741                    Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
1742                });
1743                hits.push(HitRegion {
1744                    id: view_id,
1745                    rect,
1746                    on_click,
1747                    on_scroll: None,
1748                    focusable: true,
1749                    on_pointer_down: None,
1750                    on_pointer_move: None,
1751                    on_pointer_up: None,
1752                    on_pointer_enter: None,
1753                    on_pointer_leave: None,
1754                    z_index: modifier.z_index,
1755                    on_text_change: None,
1756                    on_text_submit: None,
1757                    tf_state_key: None,
1758                    tf_multiline: false,
1759                    on_action: modifier.on_action.clone(),
1760                    cursor: modifier.cursor,
1761
1762                    on_drag_start: modifier.on_drag_start.clone(),
1763                    on_drag_end: modifier.on_drag_end.clone(),
1764                    on_drag_enter: modifier.on_drag_enter.clone(),
1765                    on_drag_over: modifier.on_drag_over.clone(),
1766                    on_drag_leave: modifier.on_drag_leave.clone(),
1767                    on_drop: modifier.on_drop.clone(),
1768                });
1769                sems.push(SemNode {
1770                    id: view_id,
1771                    parent: sem_parent,
1772                    role: Role::Checkbox,
1773                    label: None,
1774                    rect,
1775                    focused: is_focused,
1776                    enabled: true,
1777                });
1778                next_sem_parent = Some(view_id);
1779                if is_focused {
1780                    scene.nodes.push(SceneNode::Border {
1781                        rect,
1782                        color: th.focus,
1783                        width: dp_to_px(2.0),
1784                        radius: dp_to_px(6.0),
1785                    });
1786                }
1787            }
1788            ViewKind::RadioButton {
1789                selected,
1790                on_select,
1791            } => {
1792                let th = locals::theme();
1793                let d = dp_to_px(18.0);
1794                let cy = rect.y + (rect.h - d) * 0.5;
1795                scene.nodes.push(SceneNode::Border {
1796                    rect: repose_core::Rect {
1797                        x: rect.x,
1798                        y: cy,
1799                        w: d,
1800                        h: d,
1801                    },
1802                    color: th.outline,
1803                    width: dp_to_px(1.5),
1804                    radius: d * 0.5,
1805                });
1806                if *selected {
1807                    scene.nodes.push(SceneNode::Rect {
1808                        rect: repose_core::Rect {
1809                            x: rect.x + dp_to_px(4.0),
1810                            y: cy + dp_to_px(4.0),
1811                            w: d - dp_to_px(8.0),
1812                            h: d - dp_to_px(8.0),
1813                        },
1814                        brush: Brush::Solid(th.primary),
1815                        radius: (d - dp_to_px(8.0)) * 0.5,
1816                    });
1817                }
1818                if !modifier.hit_passthrough {
1819                    hits.push(HitRegion {
1820                        id: view_id,
1821                        rect,
1822                        on_click: on_select.clone(),
1823                        on_scroll: None,
1824                        focusable: true,
1825                        on_pointer_down: None,
1826                        on_pointer_move: None,
1827                        on_pointer_up: None,
1828                        on_pointer_enter: None,
1829                        on_pointer_leave: None,
1830                        z_index: modifier.z_index,
1831                        on_text_change: None,
1832                        on_text_submit: None,
1833                        tf_state_key: None,
1834                        tf_multiline: false,
1835                        on_action: modifier.on_action.clone(),
1836                        cursor: modifier.cursor,
1837
1838                        on_drag_start: modifier.on_drag_start.clone(),
1839                        on_drag_end: modifier.on_drag_end.clone(),
1840                        on_drag_enter: modifier.on_drag_enter.clone(),
1841                        on_drag_over: modifier.on_drag_over.clone(),
1842                        on_drag_leave: modifier.on_drag_leave.clone(),
1843                        on_drop: modifier.on_drop.clone(),
1844                    });
1845                }
1846                sems.push(SemNode {
1847                    id: view_id,
1848                    parent: sem_parent,
1849                    role: Role::RadioButton,
1850                    label: None,
1851                    rect,
1852                    focused: is_focused,
1853                    enabled: true,
1854                });
1855                next_sem_parent = Some(view_id);
1856                if is_focused {
1857                    scene.nodes.push(SceneNode::Border {
1858                        rect,
1859                        color: th.focus,
1860                        width: dp_to_px(2.0),
1861                        radius: dp_to_px(6.0),
1862                    });
1863                }
1864            }
1865            ViewKind::Switch { checked, on_change } => {
1866                let th = locals::theme();
1867                let tw = dp_to_px(46.0);
1868                let th_h = dp_to_px(26.0);
1869                let ty = rect.y + (rect.h - th_h) * 0.5;
1870                scene.nodes.push(SceneNode::Rect {
1871                    rect: repose_core::Rect {
1872                        x: rect.x,
1873                        y: ty,
1874                        w: tw,
1875                        h: th_h,
1876                    },
1877                    brush: Brush::Solid(if *checked { th.primary } else { th.outline }),
1878                    radius: th_h * 0.5,
1879                });
1880                let kw = dp_to_px(22.0);
1881                let kx = if *checked {
1882                    rect.x + tw - kw - dp_to_px(2.0)
1883                } else {
1884                    rect.x + dp_to_px(2.0)
1885                };
1886                scene.nodes.push(SceneNode::Rect {
1887                    rect: repose_core::Rect {
1888                        x: kx,
1889                        y: ty + (th_h - kw) * 0.5,
1890                        w: kw,
1891                        h: kw,
1892                    },
1893                    brush: Brush::Solid(th.on_surface),
1894                    radius: kw * 0.5,
1895                });
1896                let t = !*checked;
1897                let on_click = on_change.as_ref().map(|cb| {
1898                    let cb = cb.clone();
1899                    Rc::new(move || cb(t)) as Rc<dyn Fn()>
1900                });
1901                hits.push(HitRegion {
1902                    id: view_id,
1903                    rect,
1904                    on_click,
1905                    on_scroll: None,
1906                    focusable: true,
1907                    on_pointer_down: None,
1908                    on_pointer_move: None,
1909                    on_pointer_up: None,
1910                    on_pointer_enter: None,
1911                    on_pointer_leave: None,
1912                    z_index: modifier.z_index,
1913                    on_text_change: None,
1914                    on_text_submit: None,
1915                    tf_state_key: None,
1916                    tf_multiline: false,
1917                    on_action: modifier.on_action.clone(),
1918                    cursor: modifier.cursor,
1919
1920                    on_drag_start: modifier.on_drag_start.clone(),
1921                    on_drag_end: modifier.on_drag_end.clone(),
1922                    on_drag_enter: modifier.on_drag_enter.clone(),
1923                    on_drag_over: modifier.on_drag_over.clone(),
1924                    on_drag_leave: modifier.on_drag_leave.clone(),
1925                    on_drop: modifier.on_drop.clone(),
1926                });
1927                sems.push(SemNode {
1928                    id: view_id,
1929                    parent: sem_parent,
1930                    role: Role::Switch,
1931                    label: None,
1932                    rect,
1933                    focused: is_focused,
1934                    enabled: true,
1935                });
1936                next_sem_parent = Some(view_id);
1937                if is_focused {
1938                    scene.nodes.push(SceneNode::Border {
1939                        rect,
1940                        color: th.focus,
1941                        width: dp_to_px(2.0),
1942                        radius: dp_to_px(6.0),
1943                    });
1944                }
1945            }
1946            ViewKind::Slider {
1947                value,
1948                min,
1949                max,
1950                step,
1951                on_change,
1952            } => {
1953                let th = locals::theme();
1954                let th_h = dp_to_px(4.0);
1955                let kn_d = dp_to_px(20.0);
1956                let cy = rect.y + rect.h * 0.5;
1957                scene.nodes.push(SceneNode::Rect {
1958                    rect: repose_core::Rect {
1959                        x: rect.x,
1960                        y: cy - th_h * 0.5,
1961                        w: rect.w,
1962                        h: th_h,
1963                    },
1964                    brush: Brush::Solid(th.outline),
1965                    radius: th_h * 0.5,
1966                });
1967                let t = norm(*value, *min, *max).clamp(0.0, 1.0);
1968                let kx = rect.x + t * rect.w;
1969                scene.nodes.push(SceneNode::Rect {
1970                    rect: repose_core::Rect {
1971                        x: kx - kn_d * 0.5,
1972                        y: cy - kn_d * 0.5,
1973                        w: kn_d,
1974                        h: kn_d,
1975                    },
1976                    brush: Brush::Solid(th.surface),
1977                    radius: kn_d * 0.5,
1978                });
1979                scene.nodes.push(SceneNode::Border {
1980                    rect: repose_core::Rect {
1981                        x: kx - kn_d * 0.5,
1982                        y: cy - kn_d * 0.5,
1983                        w: kn_d,
1984                        h: kn_d,
1985                    },
1986                    color: th.outline,
1987                    width: dp_to_px(1.0),
1988                    radius: kn_d * 0.5,
1989                });
1990                if let Some(cb) = on_change.clone() {
1991                    let dragging = self.slider_dragging.clone();
1992                    let id = view_id;
1993                    let r = rect;
1994                    let minv = *min;
1995                    let maxv = *max;
1996                    let stepv = *step;
1997
1998                    let on_pd = {
1999                        let cb = cb.clone();
2000                        let dragging = dragging.clone();
2001                        Rc::new(move |pe: PointerEvent| {
2002                            dragging.borrow_mut().insert(id);
2003                            (cb)(value_from_x(pe.position.x, r, minv, maxv, stepv));
2004                        }) as Rc<dyn Fn(PointerEvent)>
2005                    };
2006
2007                    let on_pm = {
2008                        let cb = cb.clone();
2009                        let dragging = dragging.clone();
2010                        Rc::new(move |pe: PointerEvent| {
2011                            if !dragging.borrow().contains(&id) {
2012                                return; // ignore hover moves
2013                            }
2014                            (cb)(value_from_x(pe.position.x, r, minv, maxv, stepv));
2015                        }) as Rc<dyn Fn(PointerEvent)>
2016                    };
2017
2018                    let on_pu = {
2019                        let dragging = dragging.clone();
2020                        Rc::new(move |_pe: PointerEvent| {
2021                            dragging.borrow_mut().remove(&id);
2022                        }) as Rc<dyn Fn(PointerEvent)>
2023                    };
2024
2025                    // Build on_scroll for wheel support
2026                    let on_scroll = {
2027                        let cb = cb.clone();
2028                        let cur = *value;
2029                        Rc::new(move |d: Vec2| -> Vec2 {
2030                            let dir = wheel_to_steps(d.y);
2031                            if dir == 0 {
2032                                return d;
2033                            }
2034                            let next = apply_step(cur, dir, minv, maxv, stepv);
2035                            if (next - cur).abs() > 1e-6 {
2036                                (cb)(next);
2037                                Vec2 { x: d.x, y: 0.0 }
2038                            } else {
2039                                d
2040                            }
2041                        }) as Rc<dyn Fn(Vec2) -> Vec2>
2042                    };
2043
2044                    hits.push(HitRegion {
2045                        id: view_id,
2046                        rect,
2047                        on_click: None,
2048                        on_scroll: Some(on_scroll),
2049                        focusable: true,
2050                        on_pointer_down: Some(on_pd),
2051                        on_pointer_move: Some(on_pm),
2052                        on_pointer_up: Some(on_pu),
2053                        on_pointer_enter: modifier.on_pointer_enter.clone(),
2054                        on_pointer_leave: modifier.on_pointer_leave.clone(),
2055                        z_index: modifier.z_index,
2056                        on_text_change: None,
2057                        on_text_submit: None,
2058                        tf_state_key: None,
2059                        tf_multiline: false,
2060                        on_action: modifier.on_action.clone(),
2061                        cursor: modifier.cursor,
2062
2063                        on_drag_start: modifier.on_drag_start.clone(),
2064                        on_drag_end: modifier.on_drag_end.clone(),
2065                        on_drag_enter: modifier.on_drag_enter.clone(),
2066                        on_drag_over: modifier.on_drag_over.clone(),
2067                        on_drag_leave: modifier.on_drag_leave.clone(),
2068                        on_drop: modifier.on_drop.clone(),
2069                    });
2070                }
2071                sems.push(SemNode {
2072                    id: view_id,
2073                    parent: sem_parent,
2074                    role: Role::Slider,
2075                    label: None,
2076                    rect,
2077                    focused: is_focused,
2078                    enabled: true,
2079                });
2080                next_sem_parent = Some(view_id);
2081                if is_focused {
2082                    scene.nodes.push(SceneNode::Border {
2083                        rect,
2084                        color: th.focus,
2085                        width: dp_to_px(2.0),
2086                        radius: dp_to_px(6.0),
2087                    });
2088                }
2089            }
2090            ViewKind::RangeSlider {
2091                start,
2092                end,
2093                min,
2094                max,
2095                step,
2096                on_change,
2097            } => {
2098                let th = locals::theme();
2099                let th_h = dp_to_px(4.0);
2100                let kn_d = dp_to_px(20.0);
2101                let cy = rect.y + rect.h * 0.5;
2102                scene.nodes.push(SceneNode::Rect {
2103                    rect: repose_core::Rect {
2104                        x: rect.x,
2105                        y: cy - th_h * 0.5,
2106                        w: rect.w,
2107                        h: th_h,
2108                    },
2109                    brush: Brush::Solid(th.outline),
2110                    radius: th_h * 0.5,
2111                });
2112                let t0 = norm(*start, *min, *max).clamp(0.0, 1.0);
2113                let t1 = norm(*end, *min, *max).clamp(0.0, 1.0);
2114                let k0 = rect.x + t0 * rect.w;
2115                let k1 = rect.x + t1 * rect.w;
2116                scene.nodes.push(SceneNode::Rect {
2117                    rect: repose_core::Rect {
2118                        x: k0.min(k1),
2119                        y: cy - th_h * 0.5,
2120                        w: (k1 - k0).abs(),
2121                        h: th_h,
2122                    },
2123                    brush: Brush::Solid(th.primary),
2124                    radius: th_h * 0.5,
2125                });
2126                scene.nodes.push(SceneNode::Rect {
2127                    rect: repose_core::Rect {
2128                        x: k0 - kn_d * 0.5,
2129                        y: cy - kn_d * 0.5,
2130                        w: kn_d,
2131                        h: kn_d,
2132                    },
2133                    brush: Brush::Solid(th.surface),
2134                    radius: kn_d * 0.5,
2135                });
2136                scene.nodes.push(SceneNode::Rect {
2137                    rect: repose_core::Rect {
2138                        x: k1 - kn_d * 0.5,
2139                        y: cy - kn_d * 0.5,
2140                        w: kn_d,
2141                        h: kn_d,
2142                    },
2143                    brush: Brush::Solid(th.surface),
2144                    radius: kn_d * 0.5,
2145                });
2146                sems.push(SemNode {
2147                    id: view_id,
2148                    parent: sem_parent,
2149                    role: Role::Slider,
2150                    label: None,
2151                    rect,
2152                    focused: is_focused,
2153                    enabled: true,
2154                });
2155                next_sem_parent = Some(view_id);
2156                if is_focused {
2157                    scene.nodes.push(SceneNode::Border {
2158                        rect,
2159                        color: th.focus,
2160                        width: dp_to_px(2.0),
2161                        radius: dp_to_px(6.0),
2162                    });
2163                }
2164
2165                if let Some(cb) = on_change.clone() {
2166                    let dragging = self.slider_dragging.clone();
2167                    let active = self.range_active_thumb.clone();
2168
2169                    let id = view_id;
2170                    let r = rect;
2171                    let minv = *min;
2172                    let maxv = *max;
2173                    let stepv = *step;
2174
2175                    let start0 = *start;
2176                    let end0 = *end;
2177
2178                    let on_pd = {
2179                        let cb = cb.clone();
2180                        let dragging = dragging.clone();
2181                        let active = active.clone();
2182                        Rc::new(move |pe: PointerEvent| {
2183                            dragging.borrow_mut().insert(id);
2184
2185                            // choose thumb based on which value is closer at press-time
2186                            let v = value_from_x(pe.position.x, r, minv, maxv, stepv);
2187                            let use_end = (v - end0).abs() < (v - start0).abs();
2188                            active.borrow_mut().insert(id, use_end);
2189
2190                            let (mut a, mut b) = (start0, end0);
2191                            if use_end {
2192                                b = v.max(a);
2193                            } else {
2194                                a = v.min(b);
2195                            }
2196                            (cb)(a, b);
2197                        }) as Rc<dyn Fn(PointerEvent)>
2198                    };
2199
2200                    let on_pm = {
2201                        let cb = cb.clone();
2202                        let dragging = dragging.clone();
2203                        let active = active.clone();
2204                        Rc::new(move |pe: PointerEvent| {
2205                            if !dragging.borrow().contains(&id) {
2206                                return;
2207                            }
2208                            let v = value_from_x(pe.position.x, r, minv, maxv, stepv);
2209                            let use_end = *active.borrow().get(&id).unwrap_or(&false);
2210
2211                            let (mut a, mut b) = (start0, end0);
2212                            if use_end {
2213                                b = v.max(a);
2214                            } else {
2215                                a = v.min(b);
2216                            }
2217                            (cb)(a, b);
2218                        }) as Rc<dyn Fn(PointerEvent)>
2219                    };
2220
2221                    let on_pu = {
2222                        let dragging = dragging.clone();
2223                        let active = active.clone();
2224                        Rc::new(move |_pe: PointerEvent| {
2225                            dragging.borrow_mut().remove(&id);
2226                            active.borrow_mut().remove(&id);
2227                        }) as Rc<dyn Fn(PointerEvent)>
2228                    };
2229
2230                    // Build on_scroll for wheel support
2231                    let on_scroll = {
2232                        let cb = cb.clone();
2233                        let cur_a = *start;
2234                        let cur_b = *end;
2235                        let active_map = active.clone();
2236
2237                        Rc::new(move |d: Vec2| -> Vec2 {
2238                            let dir = wheel_to_steps(d.y);
2239                            if dir == 0 {
2240                                return d;
2241                            }
2242
2243                            // Use end thumb by default if no active thumb stored
2244                            let use_end = *active_map.borrow().get(&id).unwrap_or(&true);
2245
2246                            let (mut a, mut b) = (cur_a, cur_b);
2247                            if use_end {
2248                                b = apply_step(b, dir, minv, maxv, stepv).max(a);
2249                            } else {
2250                                a = apply_step(a, dir, minv, maxv, stepv).min(b);
2251                            }
2252
2253                            if (a - cur_a).abs() > 1e-6 || (b - cur_b).abs() > 1e-6 {
2254                                (cb)(a, b);
2255                                Vec2 { x: d.x, y: 0.0 }
2256                            } else {
2257                                d
2258                            }
2259                        }) as Rc<dyn Fn(Vec2) -> Vec2>
2260                    };
2261
2262                    hits.push(HitRegion {
2263                        id: view_id,
2264                        rect,
2265                        on_click: None,
2266                        on_scroll: Some(on_scroll),
2267                        focusable: true,
2268                        on_pointer_down: Some(on_pd),
2269                        on_pointer_move: Some(on_pm),
2270                        on_pointer_up: Some(on_pu),
2271                        on_pointer_enter: modifier.on_pointer_enter.clone(),
2272                        on_pointer_leave: modifier.on_pointer_leave.clone(),
2273                        z_index: modifier.z_index,
2274                        on_text_change: None,
2275                        on_text_submit: None,
2276                        tf_state_key: None,
2277                        tf_multiline: false,
2278                        on_action: modifier.on_action.clone(),
2279                        cursor: modifier.cursor,
2280
2281                        on_drag_start: modifier.on_drag_start.clone(),
2282                        on_drag_end: modifier.on_drag_end.clone(),
2283                        on_drag_enter: modifier.on_drag_enter.clone(),
2284                        on_drag_over: modifier.on_drag_over.clone(),
2285                        on_drag_leave: modifier.on_drag_leave.clone(),
2286                        on_drop: modifier.on_drop.clone(),
2287                    });
2288                }
2289            }
2290            ViewKind::ProgressBar {
2291                value, min, max, ..
2292            } => {
2293                let th = locals::theme();
2294                let th_h = dp_to_px(6.0);
2295                let cy = rect.y + rect.h * 0.5;
2296                scene.nodes.push(SceneNode::Rect {
2297                    rect: repose_core::Rect {
2298                        x: rect.x,
2299                        y: cy - th_h * 0.5,
2300                        w: rect.w,
2301                        h: th_h,
2302                    },
2303                    brush: Brush::Solid(th.outline),
2304                    radius: th_h * 0.5,
2305                });
2306                let t = norm(*value, *min, *max).clamp(0.0, 1.0);
2307                scene.nodes.push(SceneNode::Rect {
2308                    rect: repose_core::Rect {
2309                        x: rect.x,
2310                        y: cy - th_h * 0.5,
2311                        w: rect.w * t,
2312                        h: th_h,
2313                    },
2314                    brush: Brush::Solid(th.primary),
2315                    radius: th_h * 0.5,
2316                });
2317                sems.push(SemNode {
2318                    id: view_id,
2319                    parent: sem_parent,
2320                    role: Role::ProgressBar,
2321                    label: None,
2322                    rect,
2323                    focused: is_focused,
2324                    enabled: true,
2325                });
2326                next_sem_parent = Some(view_id);
2327            }
2328            _ => {}
2329        }
2330
2331        // Children
2332        let child_offset_px = base_px;
2333        match &kind {
2334            ViewKind::ScrollV {
2335                on_scroll,
2336                set_viewport_height,
2337                set_content_height,
2338                get_scroll_offset,
2339                set_scroll_offset,
2340            } => {
2341                hits.push(HitRegion {
2342                    id: view_id,
2343                    rect,
2344                    on_click: None,
2345                    on_scroll: on_scroll.clone(),
2346                    focusable: false,
2347                    on_pointer_down: modifier.on_pointer_down.clone(),
2348                    on_pointer_move: modifier.on_pointer_move.clone(),
2349                    on_pointer_up: modifier.on_pointer_up.clone(),
2350                    on_pointer_enter: modifier.on_pointer_enter.clone(),
2351                    on_pointer_leave: modifier.on_pointer_leave.clone(),
2352                    z_index: modifier.z_index,
2353                    on_text_change: None,
2354                    on_text_submit: None,
2355                    tf_state_key: None,
2356                    tf_multiline: false,
2357                    on_action: modifier.on_action.clone(),
2358                    cursor: modifier.cursor,
2359
2360                    on_drag_start: modifier.on_drag_start.clone(),
2361                    on_drag_end: modifier.on_drag_end.clone(),
2362                    on_drag_enter: modifier.on_drag_enter.clone(),
2363                    on_drag_over: modifier.on_drag_over.clone(),
2364                    on_drag_leave: modifier.on_drag_leave.clone(),
2365                    on_drop: modifier.on_drop.clone(),
2366                });
2367                let vp = content_rect;
2368                if let Some(s) = set_viewport_height {
2369                    s(vp.h.max(0.0));
2370                }
2371                let mut ch = 0.0f32;
2372                for &c in &children {
2373                    let l = self.taffy.layout(self.taffy_map[&c]).unwrap();
2374                    ch = ch.max(l.location.y + l.size.height);
2375                }
2376                if let Some(s) = set_content_height {
2377                    s(ch);
2378                }
2379                let off = get_scroll_offset.as_ref().map(|f| f()).unwrap_or(0.0);
2380
2381                scene.nodes.push(SceneNode::PushClip {
2382                    rect: vp,
2383                    radius: 0.0,
2384                });
2385
2386                let hits_start = hits.len();
2387                let scrolled_offset = (child_offset_px.0, child_offset_px.1 - off);
2388
2389                // Optional (recommended): cull children outside the viewport to help LazyColumn
2390                for &child_id in &children {
2391                    let l = self.taffy.layout(self.taffy_map[&child_id]).unwrap();
2392                    let child_rect = repose_core::Rect {
2393                        x: scrolled_offset.0 + l.location.x,
2394                        y: scrolled_offset.1 + l.location.y,
2395                        w: l.size.width,
2396                        h: l.size.height,
2397                    };
2398                    if intersect_rect(child_rect, vp).is_none() {
2399                        self.stats.paint_culled += 1;
2400                        continue;
2401                    }
2402
2403                    self.walk_paint(
2404                        child_id,
2405                        scene,
2406                        hits,
2407                        sems,
2408                        textfield_states,
2409                        interactions,
2410                        focused,
2411                        scrolled_offset,
2412                        alpha_accum,
2413                        next_sem_parent,
2414                        font_px,
2415                        allow_cache,
2416                        deferred,
2417                        skip_defer,
2418                    );
2419                }
2420
2421                // Clip hits to viewport
2422                let mut i = hits_start;
2423                while i < hits.len() {
2424                    if let Some(r) = intersect_rect(hits[i].rect, vp) {
2425                        hits[i].rect = r;
2426                        i += 1;
2427                    } else {
2428                        hits.remove(i);
2429                    }
2430                }
2431
2432                push_scrollbar_v(
2433                    scene,
2434                    hits,
2435                    interactions,
2436                    view_id,
2437                    vp,
2438                    ch,
2439                    off,
2440                    modifier.z_index,
2441                    set_scroll_offset.clone(),
2442                );
2443
2444                scene.nodes.push(SceneNode::PopClip);
2445            }
2446            ViewKind::ScrollXY {
2447                on_scroll,
2448                set_viewport_width,
2449                set_viewport_height,
2450                set_content_width,
2451                set_content_height,
2452                get_scroll_offset_xy,
2453                set_scroll_offset_xy,
2454            } => {
2455                hits.push(HitRegion {
2456                    id: view_id,
2457                    rect,
2458                    on_click: None,
2459                    on_scroll: on_scroll.clone(),
2460                    focusable: false,
2461                    on_pointer_down: modifier.on_pointer_down.clone(),
2462                    on_pointer_move: modifier.on_pointer_move.clone(),
2463                    on_pointer_up: modifier.on_pointer_up.clone(),
2464                    on_pointer_enter: modifier.on_pointer_enter.clone(),
2465                    on_pointer_leave: modifier.on_pointer_leave.clone(),
2466                    z_index: modifier.z_index,
2467                    on_text_change: None,
2468                    on_text_submit: None,
2469                    tf_state_key: None,
2470                    tf_multiline: false,
2471                    on_action: modifier.on_action.clone(),
2472                    cursor: modifier.cursor,
2473
2474                    on_drag_start: modifier.on_drag_start.clone(),
2475                    on_drag_end: modifier.on_drag_end.clone(),
2476                    on_drag_enter: modifier.on_drag_enter.clone(),
2477                    on_drag_over: modifier.on_drag_over.clone(),
2478                    on_drag_leave: modifier.on_drag_leave.clone(),
2479                    on_drop: modifier.on_drop.clone(),
2480                });
2481                let vp = content_rect;
2482                if let Some(s) = set_viewport_width {
2483                    s(vp.w.max(0.0));
2484                }
2485                if let Some(s) = set_viewport_height {
2486                    s(vp.h.max(0.0));
2487                }
2488                let mut cw = 0.0f32;
2489                let mut ch = 0.0f32;
2490                for &c in &children {
2491                    let l = self.taffy.layout(self.taffy_map[&c]).unwrap();
2492                    cw = cw.max(l.location.x + l.size.width);
2493                    ch = ch.max(l.location.y + l.size.height);
2494                }
2495                if let Some(s) = set_content_width {
2496                    s(cw);
2497                }
2498                if let Some(s) = set_content_height {
2499                    s(ch);
2500                }
2501                let (ox, oy) = get_scroll_offset_xy
2502                    .as_ref()
2503                    .map(|f| f())
2504                    .unwrap_or((0.0, 0.0));
2505
2506                scene.nodes.push(SceneNode::PushClip {
2507                    rect: vp,
2508                    radius: 0.0,
2509                });
2510                let hits_start = hits.len();
2511                let scrolled_offset = (child_offset_px.0 - ox, child_offset_px.1 - oy);
2512                for &child_id in &children {
2513                    self.walk_paint(
2514                        child_id,
2515                        scene,
2516                        hits,
2517                        sems,
2518                        textfield_states,
2519                        interactions,
2520                        focused,
2521                        scrolled_offset,
2522                        alpha_accum,
2523                        next_sem_parent,
2524                        font_px,
2525                        allow_cache,
2526                        deferred,
2527                        skip_defer,
2528                    );
2529                }
2530                let mut i = hits_start;
2531                while i < hits.len() {
2532                    if let Some(r) = intersect_rect(hits[i].rect, vp) {
2533                        hits[i].rect = r;
2534                        i += 1;
2535                    } else {
2536                        hits.remove(i);
2537                    }
2538                }
2539                let set_y = set_scroll_offset_xy.clone().map(|s| {
2540                    let ox = ox;
2541                    Rc::new(move |y| s(ox, y)) as Rc<dyn Fn(f32)>
2542                });
2543                push_scrollbar_v(
2544                    scene,
2545                    hits,
2546                    interactions,
2547                    view_id,
2548                    vp,
2549                    ch,
2550                    oy,
2551                    modifier.z_index,
2552                    set_y,
2553                );
2554                push_scrollbar_h(
2555                    scene,
2556                    hits,
2557                    interactions,
2558                    view_id,
2559                    vp,
2560                    cw,
2561                    ox,
2562                    modifier.z_index,
2563                    set_scroll_offset_xy.clone(),
2564                    oy,
2565                );
2566                scene.nodes.push(SceneNode::PopClip);
2567            }
2568            ViewKind::OverlayHost => {
2569                for &child_id in &children {
2570                    self.walk_paint(
2571                        child_id,
2572                        scene,
2573                        hits,
2574                        sems,
2575                        textfield_states,
2576                        interactions,
2577                        focused,
2578                        child_offset_px,
2579                        alpha_accum,
2580                        next_sem_parent,
2581                        font_px,
2582                        allow_cache,
2583                        deferred,
2584                        skip_defer,
2585                    );
2586                }
2587            }
2588            _ => {
2589                for &child_id in &children {
2590                    self.walk_paint(
2591                        child_id,
2592                        scene,
2593                        hits,
2594                        sems,
2595                        textfield_states,
2596                        interactions,
2597                        focused,
2598                        child_offset_px,
2599                        alpha_accum,
2600                        next_sem_parent,
2601                        font_px,
2602                        allow_cache,
2603                        deferred,
2604                        skip_defer,
2605                    );
2606                }
2607            }
2608        }
2609
2610        if push_round_clip {
2611            scene.nodes.push(SceneNode::PopClip);
2612        }
2613        if modifier.transform.is_some() {
2614            scene.nodes.push(SceneNode::PopTransform);
2615        }
2616    }
2617}
2618
2619// Helpers
2620fn infer_label(tree: &ViewTree, node_id: NodeId) -> Option<String> {
2621    let mut stack = vec![node_id];
2622    while let Some(id) = stack.pop() {
2623        let n = tree.get(id)?;
2624        if let ViewKind::Text { text, .. } = &n.kind {
2625            if !text.is_empty() {
2626                return Some(text.clone());
2627            }
2628        }
2629        for &ch in n.children.iter().rev() {
2630            stack.push(ch);
2631        }
2632    }
2633    None
2634}
2635
2636fn intersect_rect(a: repose_core::Rect, b: repose_core::Rect) -> Option<repose_core::Rect> {
2637    let x0 = a.x.max(b.x);
2638    let y0 = a.y.max(b.y);
2639    let x1 = (a.x + a.w).min(b.x + b.w);
2640    let y1 = (a.y + a.h).min(b.y + b.h);
2641    let w = (x1 - x0).max(0.0);
2642    let h = (y1 - y0).max(0.0);
2643    if w <= 0.0 || h <= 0.0 {
2644        None
2645    } else {
2646        Some(repose_core::Rect { x: x0, y: y0, w, h })
2647    }
2648}
2649
2650fn mul_alpha_color(c: Color, a: f32) -> Color {
2651    Color(c.0, c.1, c.2, ((c.3 as f32) * a).clamp(0.0, 255.0) as u8)
2652}
2653fn mul_alpha_brush(b: Brush, a: f32) -> Brush {
2654    match b {
2655        Brush::Solid(c) => Brush::Solid(mul_alpha_color(c, a)),
2656        Brush::Linear {
2657            start,
2658            end,
2659            start_color,
2660            end_color,
2661        } => Brush::Linear {
2662            start,
2663            end,
2664            start_color: mul_alpha_color(start_color, a),
2665            end_color: mul_alpha_color(end_color, a),
2666        },
2667    }
2668}
2669
2670fn clamp_radius(r: f32, w: f32, h: f32) -> f32 {
2671    r.max(0.0).min(0.5 * w.max(0.0)).min(0.5 * h.max(0.0))
2672}
2673fn norm(v: f32, min: f32, max: f32) -> f32 {
2674    if max > min {
2675        (v - min) / (max - min)
2676    } else {
2677        0.0
2678    }
2679}
2680
2681fn push_scrollbar_v(
2682    scene: &mut Scene,
2683    hits: &mut Vec<HitRegion>,
2684    interactions: &Interactions,
2685    vid: u64,
2686    vp: repose_core::Rect,
2687    ch: f32,
2688    off: f32,
2689    z: f32,
2690    set: Option<Rc<dyn Fn(f32)>>,
2691) {
2692    if ch <= vp.h + 0.5 {
2693        return;
2694    }
2695    let thick = dp_to_px(6.0);
2696    let m = dp_to_px(2.0);
2697    let tx = vp.x + vp.w - m - thick;
2698    let ty = vp.y + m;
2699    let th = (vp.h - 2.0 * m).max(0.0);
2700    if th <= 0.5 {
2701        return;
2702    }
2703    let ratio = (vp.h / ch).clamp(0.0, 1.0);
2704    let thumb_h = (th * ratio).max(dp_to_px(24.0)).min(th);
2705    let tpos = (off / (ch - vp.h).max(1.0)).clamp(0.0, 1.0);
2706    let thumb_y = ty + tpos * (th - thumb_h);
2707    let color = locals::theme().scrollbar_thumb;
2708
2709    scene.nodes.push(SceneNode::Rect {
2710        rect: repose_core::Rect {
2711            x: tx,
2712            y: ty,
2713            w: thick,
2714            h: th,
2715        },
2716        brush: Brush::Solid(locals::theme().scrollbar_track),
2717        radius: thick * 0.5,
2718    });
2719    scene.nodes.push(SceneNode::Rect {
2720        rect: repose_core::Rect {
2721            x: tx,
2722            y: thumb_y,
2723            w: thick,
2724            h: thumb_h,
2725        },
2726        brush: Brush::Solid(color),
2727        radius: thick * 0.5,
2728    });
2729
2730    if let Some(s) = set {
2731        let tid = vid ^ 0x8000_0001;
2732        let map = Rc::new(move |py: f32| -> f32 {
2733            let max_p = (th - thumb_h).max(0.0);
2734            let p = ((py - ty) - thumb_h * 0.5).clamp(0.0, max_p);
2735            (if max_p > 0.0 { p / max_p } else { 0.0 }) * (ch - vp.h).max(1.0)
2736        });
2737        let on_pd = {
2738            let s = s.clone();
2739            let m = map.clone();
2740            Rc::new(move |pe: PointerEvent| s(m(pe.position.y)))
2741        };
2742        let on_pm = if interactions.pressed.contains(&tid) {
2743            let s = s.clone();
2744            let m = map.clone();
2745            Some(Rc::new(move |pe: PointerEvent| s(m(pe.position.y))) as Rc<dyn Fn(PointerEvent)>)
2746        } else {
2747            None
2748        };
2749        hits.push(HitRegion {
2750            id: tid,
2751            rect: repose_core::Rect {
2752                x: tx,
2753                y: thumb_y,
2754                w: thick,
2755                h: thumb_h,
2756            },
2757            on_click: None,
2758            on_scroll: None,
2759            focusable: false,
2760            on_pointer_down: Some(on_pd),
2761            on_pointer_move: on_pm,
2762            on_pointer_up: Some(Rc::new(|_| {})),
2763            on_pointer_enter: None,
2764            on_action: None,
2765            cursor: None,
2766            tf_multiline: false,
2767
2768            on_drag_start: None,
2769            on_drag_end: None,
2770            on_drag_enter: None,
2771            on_drag_over: None,
2772            on_drag_leave: None,
2773            on_drop: None,
2774
2775            on_pointer_leave: None,
2776            z_index: z + 1000.0,
2777            on_text_change: None,
2778            on_text_submit: None,
2779            tf_state_key: None,
2780        });
2781    }
2782}
2783
2784fn push_scrollbar_h(
2785    scene: &mut Scene,
2786    hits: &mut Vec<HitRegion>,
2787    interactions: &Interactions,
2788    vid: u64,
2789    vp: repose_core::Rect,
2790    cw: f32,
2791    off: f32,
2792    z: f32,
2793    set: Option<Rc<dyn Fn(f32, f32)>>,
2794    keep_y: f32,
2795) {
2796    if cw <= vp.w + 0.5 {
2797        return;
2798    }
2799    let thick = dp_to_px(6.0);
2800    let m = dp_to_px(2.0);
2801    let tx = vp.x + m;
2802    let ty = vp.y + vp.h - m - thick;
2803    let tw = (vp.w - 2.0 * m).max(0.0);
2804    if tw <= 0.5 {
2805        return;
2806    }
2807    let ratio = (vp.w / cw).clamp(0.0, 1.0);
2808    let thumb_w = (tw * ratio).max(dp_to_px(24.0)).min(tw);
2809    let tpos = (off / (cw - vp.w).max(1.0)).clamp(0.0, 1.0);
2810    let thumb_x = tx + tpos * (tw - thumb_w);
2811    let color = locals::theme().scrollbar_thumb;
2812
2813    scene.nodes.push(SceneNode::Rect {
2814        rect: repose_core::Rect {
2815            x: tx,
2816            y: ty,
2817            w: tw,
2818            h: thick,
2819        },
2820        brush: Brush::Solid(locals::theme().scrollbar_track),
2821        radius: thick * 0.5,
2822    });
2823    scene.nodes.push(SceneNode::Rect {
2824        rect: repose_core::Rect {
2825            x: thumb_x,
2826            y: ty,
2827            w: thumb_w,
2828            h: thick,
2829        },
2830        brush: Brush::Solid(color),
2831        radius: thick * 0.5,
2832    });
2833    if let Some(s) = set {
2834        let tid = vid ^ 0x8000_0002;
2835        let map = Rc::new(move |px: f32| -> f32 {
2836            let max_p = (tw - thumb_w).max(0.0);
2837            let p = ((px - tx) - thumb_w * 0.5).clamp(0.0, max_p);
2838            (if max_p > 0.0 { p / max_p } else { 0.0 }) * (cw - vp.w).max(1.0)
2839        });
2840        let on_pd = {
2841            let s = s.clone();
2842            let m = map.clone();
2843            Rc::new(move |pe: PointerEvent| s(m(pe.position.x), keep_y))
2844        };
2845        let on_pm = if interactions.pressed.contains(&tid) {
2846            let s = s.clone();
2847            let m = map.clone();
2848            Some(Rc::new(move |pe: PointerEvent| s(m(pe.position.x), keep_y))
2849                as Rc<dyn Fn(PointerEvent)>)
2850        } else {
2851            None
2852        };
2853        hits.push(HitRegion {
2854            id: tid,
2855            rect: repose_core::Rect {
2856                x: thumb_x,
2857                y: ty,
2858                w: thumb_w,
2859                h: thick,
2860            },
2861            on_click: None,
2862            on_scroll: None,
2863            focusable: false,
2864            on_pointer_down: Some(on_pd),
2865            on_pointer_move: on_pm,
2866            on_pointer_up: Some(Rc::new(|_| {})),
2867            on_pointer_enter: None,
2868            on_action: None,
2869            cursor: None,
2870            tf_multiline: false,
2871
2872            on_drag_start: None,
2873            on_drag_end: None,
2874            on_drag_enter: None,
2875            on_drag_over: None,
2876            on_drag_leave: None,
2877            on_drop: None,
2878
2879            on_pointer_leave: None,
2880            z_index: z + 1000.0,
2881            on_text_change: None,
2882            on_text_submit: None,
2883            tf_state_key: None,
2884        });
2885    }
2886}
2887
2888fn snap_step(v: f32, min: f32, max: f32, step: Option<f32>) -> f32 {
2889    let v = v.clamp(min, max);
2890    if let Some(s) = step.filter(|s| *s > 0.0) {
2891        let t = ((v - min) / s).round();
2892        (min + t * s).clamp(min, max)
2893    } else {
2894        v
2895    }
2896}
2897
2898fn value_from_x(x: f32, rect: repose_core::Rect, min: f32, max: f32, step: Option<f32>) -> f32 {
2899    let w = rect.w.max(1.0);
2900    let t = ((x - rect.x) / w).clamp(0.0, 1.0);
2901    let v = min + t * (max - min);
2902    snap_step(v, min, max, step)
2903}
2904
2905fn wheel_to_steps(dy_px: f32) -> i32 {
2906    if dy_px < -0.5 {
2907        1
2908    } else if dy_px > 0.5 {
2909        -1
2910    } else {
2911        0
2912    }
2913}
2914
2915fn apply_step(v: f32, dir: i32, min: f32, max: f32, step: Option<f32>) -> f32 {
2916    if dir == 0 {
2917        return v;
2918    }
2919    let s = step.unwrap_or(1.0).max(1e-6);
2920    snap_step(v + (dir as f32) * s, min, max, step)
2921}
2922
2923#[cfg(test)]
2924mod tests {
2925    use super::*;
2926    use crate::{Box as RBox, Column, Stack, ViewExt};
2927
2928    fn font_px(dp: f32) -> f32 {
2929        dp // 1:1 for tests
2930    }
2931
2932    #[test]
2933    fn test_render_z_index_paints_last() {
2934        // Create a Stack with two children:
2935        // 1. A red box (painted first, no render_z_index)
2936        // 2. A blue box with render_z_index(100.0) (should be painted last)
2937
2938        let red = Color::from_rgb(255, 0, 0);
2939        let blue = Color::from_rgb(0, 0, 255);
2940
2941        let red_box = RBox(Modifier::new().size(100.0, 100.0).background(red));
2942        let blue_box = RBox(
2943            Modifier::new()
2944                .size(100.0, 100.0)
2945                .background(blue)
2946                .render_z_index(100.0),
2947        );
2948
2949        let root = Stack(Modifier::new().size(200.0, 200.0)).child((red_box, blue_box));
2950
2951        let mut engine = LayoutEngine::new();
2952        let (scene, _hits, _sems) = engine.layout_frame(
2953            &root,
2954            (200, 200),
2955            &HashMap::new(),
2956            &Interactions::default(),
2957            None,
2958        );
2959
2960        // Find the rect nodes - there should be two (red and blue backgrounds)
2961        let rects: Vec<_> = scene
2962            .nodes
2963            .iter()
2964            .filter_map(|n| {
2965                if let SceneNode::Rect { brush, .. } = n {
2966                    Some(brush.clone())
2967                } else {
2968                    None
2969                }
2970            })
2971            .collect();
2972
2973        assert!(
2974            rects.len() >= 2,
2975            "Expected at least 2 rect nodes, got {}",
2976            rects.len()
2977        );
2978
2979        // The blue box (with render_z_index) should be painted LAST
2980        // So its brush should be the last rect in the scene
2981        let last_rect_brush = rects.last().unwrap();
2982        assert!(
2983            matches!(last_rect_brush, Brush::Solid(c) if *c == blue),
2984            "Expected blue box to be painted last, but got {:?}",
2985            last_rect_brush
2986        );
2987
2988        // And the red box should be painted before the blue
2989        let second_to_last = rects.get(rects.len() - 2);
2990        assert!(second_to_last.is_some(), "Expected at least 2 rect nodes");
2991        let second_brush = second_to_last.unwrap();
2992        assert!(
2993            matches!(second_brush, Brush::Solid(c) if *c == red),
2994            "Expected red box to be painted before blue, but got {:?}",
2995            second_brush
2996        );
2997    }
2998
2999    #[test]
3000    fn test_render_z_index_order_by_value() {
3001        // Test that higher render_z_index values are painted later
3002
3003        let red = Color::from_rgb(255, 0, 0);
3004        let green = Color::from_rgb(0, 255, 0);
3005        let blue = Color::from_rgb(0, 0, 255);
3006
3007        let box1 = RBox(
3008            Modifier::new()
3009                .size(50.0, 50.0)
3010                .background(red)
3011                .render_z_index(10.0),
3012        );
3013        let box2 = RBox(
3014            Modifier::new()
3015                .size(50.0, 50.0)
3016                .background(green)
3017                .render_z_index(20.0),
3018        );
3019        let box3 = RBox(
3020            Modifier::new()
3021                .size(50.0, 50.0)
3022                .background(blue)
3023                .render_z_index(5.0),
3024        );
3025
3026        let root = Stack(Modifier::new().size(200.0, 200.0)).child((box1, box2, box3));
3027
3028        let mut engine = LayoutEngine::new();
3029        let (scene, _hits, _sems) = engine.layout_frame(
3030            &root,
3031            (200, 200),
3032            &HashMap::new(),
3033            &Interactions::default(),
3034            None,
3035        );
3036
3037        let rects: Vec<_> = scene
3038            .nodes
3039            .iter()
3040            .filter_map(|n| {
3041                if let SceneNode::Rect { brush, .. } = n {
3042                    Some(brush.clone())
3043                } else {
3044                    None
3045                }
3046            })
3047            .collect();
3048
3049        // Order should be: BLUE (z=5), RED (z=10), GREEN (z=20)
3050        assert!(rects.len() >= 3, "Expected at least 3 rects");
3051
3052        let len = rects.len();
3053        assert!(
3054            matches!(&rects[len - 3], Brush::Solid(c) if *c == blue),
3055            "Expected BLUE (z=5) third from last"
3056        );
3057        assert!(
3058            matches!(&rects[len - 2], Brush::Solid(c) if *c == red),
3059            "Expected RED (z=10) second from last"
3060        );
3061        assert!(
3062            matches!(&rects[len - 1], Brush::Solid(c) if *c == green),
3063            "Expected GREEN (z=20) last"
3064        );
3065    }
3066
3067    #[test]
3068    fn test_render_z_index_with_nested_children() {
3069        // This test mimics the showcase scenario:
3070        // Stack {
3071        //   Column { red_box, green_box }  // This is like the main content
3072        //   blue_box with render_z_index(1000)  // This is like the hint overlay
3073        // }
3074        // The blue_box should be painted AFTER all contents of Column
3075
3076        let red = Color::from_rgb(255, 0, 0);
3077        let green = Color::from_rgb(0, 255, 0);
3078        let blue = Color::from_rgb(0, 0, 255);
3079
3080        let red_box = RBox(Modifier::new().size(50.0, 50.0).background(red));
3081        let green_box = RBox(Modifier::new().size(50.0, 50.0).background(green));
3082
3083        let content = Column(Modifier::new()).child((red_box, green_box));
3084
3085        let overlay = RBox(
3086            Modifier::new()
3087                .size(30.0, 30.0)
3088                .background(blue)
3089                .render_z_index(1000.0),
3090        );
3091
3092        let root = Stack(Modifier::new().size(200.0, 200.0)).child((content, overlay));
3093
3094        let mut engine = LayoutEngine::new();
3095        let (scene, _hits, _sems) = engine.layout_frame(
3096            &root,
3097            (200, 200),
3098            &HashMap::new(),
3099            &Interactions::default(),
3100            None,
3101        );
3102
3103        let rects: Vec<_> = scene
3104            .nodes
3105            .iter()
3106            .filter_map(|n| {
3107                if let SceneNode::Rect { brush, .. } = n {
3108                    Some(brush.clone())
3109                } else {
3110                    None
3111                }
3112            })
3113            .collect();
3114
3115        // Order should be: RED, GREEN, then BLUE (because blue has render_z_index)
3116        assert!(
3117            rects.len() >= 3,
3118            "Expected at least 3 rects, got {}",
3119            rects.len()
3120        );
3121
3122        let len = rects.len();
3123        // Blue should be LAST
3124        assert!(
3125            matches!(&rects[len - 1], Brush::Solid(c) if *c == blue),
3126            "Expected BLUE (z=1000) to be painted last, but got {:?}",
3127            &rects[len - 1]
3128        );
3129
3130        // Red and Green should be before blue
3131        // Find their positions
3132        let blue_pos = rects
3133            .iter()
3134            .position(|b| matches!(b, Brush::Solid(c) if *c == blue))
3135            .unwrap();
3136        let red_pos = rects
3137            .iter()
3138            .position(|b| matches!(b, Brush::Solid(c) if *c == red))
3139            .unwrap();
3140        let green_pos = rects
3141            .iter()
3142            .position(|b| matches!(b, Brush::Solid(c) if *c == green))
3143            .unwrap();
3144
3145        assert!(red_pos < blue_pos, "Red should be painted before blue");
3146        assert!(green_pos < blue_pos, "Green should be painted before blue");
3147    }
3148
3149    #[test]
3150    fn test_render_z_index_paints_over_scrollbars() {
3151        // This test verifies that a node with render_z_index paints AFTER scrollbars
3152        // Structure:
3153        // Stack {
3154        //   Scroll(tall content)  // This will show a scrollbar
3155        //   Box with render_z_index(1000)  // This should paint LAST
3156        // }
3157        use crate::Scroll;
3158
3159        let content_color = Color::from_rgb(100, 100, 100);
3160        let overlay_color = Color::from_rgb(0, 0, 255);
3161
3162        // Tall content inside scroll - 500px tall in 200px viewport
3163        let tall_content = RBox(Modifier::new().size(180.0, 500.0).background(content_color));
3164
3165        let scroll = Scroll(Modifier::new().size(200.0, 200.0)).child(tall_content);
3166
3167        let overlay = RBox(
3168            Modifier::new()
3169                .size(50.0, 50.0)
3170                .background(overlay_color)
3171                .render_z_index(1000.0),
3172        );
3173
3174        let root = Stack(Modifier::new().size(200.0, 200.0)).child((scroll, overlay));
3175
3176        let mut engine = LayoutEngine::new();
3177        let (scene, _hits, _sems) = engine.layout_frame(
3178            &root,
3179            (200, 200),
3180            &HashMap::new(),
3181            &Interactions::default(),
3182            None,
3183        );
3184
3185        // Collect all rect nodes (this includes content, scrollbar track, scrollbar thumb, and overlay)
3186        let rects: Vec<_> = scene
3187            .nodes
3188            .iter()
3189            .filter_map(|n| {
3190                if let SceneNode::Rect { brush, .. } = n {
3191                    Some(brush.clone())
3192                } else {
3193                    None
3194                }
3195            })
3196            .collect();
3197
3198        // The overlay (blue) should be painted LAST
3199        let overlay_pos = rects
3200            .iter()
3201            .position(|b| matches!(b, Brush::Solid(c) if *c == overlay_color));
3202        assert!(
3203            overlay_pos.is_some(),
3204            "Overlay should be present in scene, rects: {:?}",
3205            rects
3206        );
3207        let overlay_pos = overlay_pos.unwrap();
3208
3209        // Check that overlay is painted last (after scrollbar)
3210        assert_eq!(
3211            overlay_pos,
3212            rects.len() - 1,
3213            "Overlay should be the last rect, but it's at position {} of {}. Rects: {:?}",
3214            overlay_pos,
3215            rects.len(),
3216            rects
3217        );
3218    }
3219
3220    #[test]
3221    fn test_render_z_index_with_overlay_host() {
3222        // This test mimics the showcase app structure more closely:
3223        // Stack {
3224        //   OverlayHost { content with Scroll }
3225        //   Box with render_z_index(1000)  // Hint box
3226        // }
3227        use crate::Scroll;
3228        use crate::overlay::OverlayHandle;
3229
3230        let content_color = Color::from_rgb(100, 100, 100);
3231        let overlay_color = Color::from_rgb(0, 0, 255);
3232
3233        // Tall content inside scroll - 500px tall in 200px viewport
3234        let tall_content = RBox(Modifier::new().size(180.0, 500.0).background(content_color));
3235        let scroll = Scroll(Modifier::new().size(200.0, 200.0)).child(tall_content);
3236
3237        // Create an OverlayHost wrapping the scroll content
3238        let overlay_handle = OverlayHandle::new();
3239        let overlay_host = overlay_handle.host(Modifier::new().fill_max_size(), scroll);
3240
3241        // The hint box with render_z_index should paint on top
3242        let hint_box = RBox(
3243            Modifier::new()
3244                .size(50.0, 50.0)
3245                .background(overlay_color)
3246                .render_z_index(1000.0),
3247        );
3248
3249        // Final structure: Stack { OverlayHost, HintBox }
3250        let root = Stack(Modifier::new().size(200.0, 200.0)).child((overlay_host, hint_box));
3251
3252        let mut engine = LayoutEngine::new();
3253        let (scene, _hits, _sems) = engine.layout_frame(
3254            &root,
3255            (200, 200),
3256            &HashMap::new(),
3257            &Interactions::default(),
3258            None,
3259        );
3260
3261        // Collect all rect nodes
3262        let rects: Vec<_> = scene
3263            .nodes
3264            .iter()
3265            .filter_map(|n| {
3266                if let SceneNode::Rect { brush, .. } = n {
3267                    Some(brush.clone())
3268                } else {
3269                    None
3270                }
3271            })
3272            .collect();
3273
3274        // The hint box (blue) should be painted LAST
3275        let overlay_pos = rects
3276            .iter()
3277            .position(|b| matches!(b, Brush::Solid(c) if *c == overlay_color));
3278        assert!(
3279            overlay_pos.is_some(),
3280            "Hint box should be present in scene, rects: {:?}",
3281            rects
3282        );
3283        let overlay_pos = overlay_pos.unwrap();
3284
3285        // Check that hint box is painted last (after scrollbar)
3286        assert_eq!(
3287            overlay_pos,
3288            rects.len() - 1,
3289            "Hint box should be the last rect, but it's at position {} of {}. Rects: {:?}",
3290            overlay_pos,
3291            rects.len(),
3292            rects
3293        );
3294    }
3295}