1#![allow(non_snake_case)]
6pub mod anim;
7pub mod lazy;
8
9use std::collections::{HashMap, HashSet};
10use std::rc::Rc;
11use std::{cell::RefCell, cmp::Ordering};
12
13use repose_core::*;
14use taffy::Overflow;
15use taffy::style::{AlignItems, Dimension, Display, FlexDirection, JustifyContent, Style};
16
17use taffy::prelude::{Position, Size, auto, length, percent};
18
19pub mod textfield;
20pub use textfield::{TextField, TextFieldState};
21
22use crate::textfield::{TF_FONT_PX, TF_PADDING_X, byte_to_char_index, measure_text, positions_for};
23use repose_core::locals;
24
25#[derive(Default)]
26pub struct Interactions {
27 pub hover: Option<u64>,
28 pub pressed: HashSet<u64>,
29}
30
31pub fn Surface(modifier: Modifier, child: View) -> View {
32 let mut v = View::new(0, ViewKind::Surface).modifier(modifier);
33 v.children = vec![child];
34 v
35}
36
37pub fn Box(modifier: Modifier) -> View {
38 View::new(0, ViewKind::Box).modifier(modifier)
39}
40
41pub fn Row(modifier: Modifier) -> View {
42 View::new(0, ViewKind::Row).modifier(modifier)
43}
44
45pub fn Column(modifier: Modifier) -> View {
46 View::new(0, ViewKind::Column).modifier(modifier)
47}
48
49pub fn Stack(modifier: Modifier) -> View {
50 View::new(0, ViewKind::Stack).modifier(modifier)
51}
52
53pub fn Scroll(modifier: Modifier) -> View {
54 View::new(
55 0,
56 ViewKind::ScrollV {
57 on_scroll: None,
58 set_viewport_height: None,
59 get_scroll_offset: None,
60 },
61 )
62 .modifier(modifier)
63}
64
65pub fn Text(text: impl Into<String>) -> View {
66 View::new(
67 0,
68 ViewKind::Text {
69 text: text.into(),
70 color: Color::WHITE,
71 font_size: 16.0,
72 },
73 )
74}
75
76pub fn Spacer() -> View {
77 Box(Modifier::new().flex_grow(1.0))
78}
79
80pub fn Grid(columns: usize, modifier: Modifier, children: Vec<View>) -> View {
81 Column(modifier.grid(columns, 0.0, 0.0)).with_children(children)
82}
83
84#[allow(non_snake_case)]
85pub fn TextColor(mut v: View, color: Color) -> View {
86 if let ViewKind::Text {
87 color: text_color, ..
88 } = &mut v.kind
89 {
90 *text_color = color;
91 }
92 v
93}
94
95#[allow(non_snake_case)]
96pub fn TextSize(mut v: View, size: f32) -> View {
97 if let ViewKind::Text {
98 font_size: text_size,
99 ..
100 } = &mut v.kind
101 {
102 *text_size = size;
103 }
104 v
105}
106
107pub fn Button(text: impl Into<String>, on_click: impl Fn() + 'static) -> View {
108 View::new(
109 0,
110 ViewKind::Button {
111 text: text.into(),
112 on_click: Some(Rc::new(on_click)),
113 },
114 )
115 .semantics(Semantics {
116 role: Role::Button,
117 label: None,
118 focused: false,
119 enabled: true,
120 })
121}
122
123pub fn Checkbox(
124 checked: bool,
125 label: impl Into<String>,
126 on_change: impl Fn(bool) + 'static,
127) -> View {
128 View::new(
129 0,
130 ViewKind::Checkbox {
131 checked,
132 label: label.into(),
133 on_change: Some(Rc::new(on_change)),
134 },
135 )
136 .semantics(Semantics {
137 role: Role::Checkbox,
138 label: None,
139 focused: false,
140 enabled: true,
141 })
142}
143
144pub fn RadioButton(
145 selected: bool,
146 label: impl Into<String>,
147 on_select: impl Fn() + 'static,
148) -> View {
149 View::new(
150 0,
151 ViewKind::RadioButton {
152 selected,
153 label: label.into(),
154 on_select: Some(Rc::new(on_select)),
155 },
156 )
157 .semantics(Semantics {
158 role: Role::RadioButton,
159 label: None,
160 focused: false,
161 enabled: true,
162 })
163}
164
165pub fn Switch(checked: bool, label: impl Into<String>, on_change: impl Fn(bool) + 'static) -> View {
166 View::new(
167 0,
168 ViewKind::Switch {
169 checked,
170 label: label.into(),
171 on_change: Some(Rc::new(on_change)),
172 },
173 )
174 .semantics(Semantics {
175 role: Role::Switch,
176 label: None,
177 focused: false,
178 enabled: true,
179 })
180}
181
182pub fn Slider(
183 value: f32,
184 range: (f32, f32),
185 step: Option<f32>,
186 label: impl Into<String>,
187 on_change: impl Fn(f32) + 'static,
188) -> View {
189 View::new(
190 0,
191 ViewKind::Slider {
192 value,
193 min: range.0,
194 max: range.1,
195 step,
196 label: label.into(),
197 on_change: Some(Rc::new(on_change)),
198 },
199 )
200 .semantics(Semantics {
201 role: Role::Slider,
202 label: None,
203 focused: false,
204 enabled: true,
205 })
206}
207
208pub fn RangeSlider(
209 start: f32,
210 end: f32,
211 range: (f32, f32),
212 step: Option<f32>,
213 label: impl Into<String>,
214 on_change: impl Fn(f32, f32) + 'static,
215) -> View {
216 View::new(
217 0,
218 ViewKind::RangeSlider {
219 start,
220 end,
221 min: range.0,
222 max: range.1,
223 step,
224 label: label.into(),
225 on_change: Some(Rc::new(on_change)),
226 },
227 )
228 .semantics(Semantics {
229 role: Role::Slider,
230 label: None,
231 focused: false,
232 enabled: true,
233 })
234}
235
236pub fn ProgressBar(value: f32, range: (f32, f32), label: impl Into<String>) -> View {
237 View::new(
238 0,
239 ViewKind::ProgressBar {
240 value,
241 min: range.0,
242 max: range.1,
243 label: label.into(),
244 circular: false,
245 },
246 )
247 .semantics(Semantics {
248 role: Role::ProgressBar,
249 label: None,
250 focused: false,
251 enabled: true,
252 })
253}
254
255fn flex_dir_for(kind: &ViewKind) -> Option<FlexDirection> {
256 match kind {
257 ViewKind::Row => {
258 if repose_core::locals::text_direction() == repose_core::locals::TextDirection::Rtl {
259 Some(FlexDirection::RowReverse)
260 } else {
261 Some(FlexDirection::Row)
262 }
263 }
264 ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. } => {
265 Some(FlexDirection::Column)
266 }
267 _ => None,
268 }
269}
270
271pub trait ViewExt: Sized {
273 fn child(self, children: impl IntoChildren) -> Self;
274}
275
276impl ViewExt for View {
277 fn child(self, children: impl IntoChildren) -> Self {
278 self.with_children(children.into_children())
279 }
280}
281
282pub trait IntoChildren {
283 fn into_children(self) -> Vec<View>;
284}
285
286impl IntoChildren for View {
287 fn into_children(self) -> Vec<View> {
288 vec![self]
289 }
290}
291
292impl IntoChildren for Vec<View> {
293 fn into_children(self) -> Vec<View> {
294 self
295 }
296}
297
298impl<const N: usize> IntoChildren for [View; N] {
299 fn into_children(self) -> Vec<View> {
300 self.into()
301 }
302}
303
304macro_rules! impl_into_children_tuple {
306 ($($idx:tt $t:ident),+) => {
307 impl<$($t: IntoChildren),+> IntoChildren for ($($t,)+) {
308 fn into_children(self) -> Vec<View> {
309 let mut v = Vec::new();
310 $(v.extend(self.$idx.into_children());)+
311 v
312 }
313 }
314 };
315}
316
317impl_into_children_tuple!(0 A, 1 B);
318impl_into_children_tuple!(0 A, 1 B, 2 C);
319impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D);
320impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E);
321impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F);
322impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G);
323impl_into_children_tuple!(0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 H);
324
325pub fn layout_and_paint(
327 root: &View,
328 size: (u32, u32),
329 textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
330 interactions: &Interactions,
331 focused: Option<u64>,
332) -> (Scene, Vec<HitRegion>, Vec<SemNode>) {
333 let mut id = 1u64;
335 fn stamp(mut v: View, id: &mut u64) -> View {
336 v.id = *id;
337 *id += 1;
338 v.children = v.children.into_iter().map(|c| stamp(c, id)).collect();
339 v
340 }
341 let root = stamp(root.clone(), &mut id);
342
343 use taffy::prelude::*;
345 #[derive(Clone)]
346 enum NodeCtx {
347 Text { text: String, font_px: f32 },
348 Button { label: String },
349 TextField,
350 Container,
351 ScrollContainer,
352 Checkbox { label: String },
353 Radio { label: String },
354 Switch { label: String },
355 Slider { label: String },
356 Range { label: String },
357 Progress { label: String },
358 }
359
360 let mut taffy: TaffyTree<NodeCtx> = TaffyTree::new();
361 let mut nodes_map = HashMap::new();
362
363 fn style_from_modifier(m: &Modifier, kind: &ViewKind) -> Style {
364 let mut s = Style::default();
365 s.display = match kind {
366 ViewKind::Row => Display::Flex,
367 ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. } => Display::Flex,
368 ViewKind::Stack => Display::Grid, _ => Display::Flex,
370 };
371 if matches!(kind, ViewKind::Row) {
372 s.flex_direction = FlexDirection::Row;
373 }
374 if matches!(
375 kind,
376 ViewKind::Column | ViewKind::Surface | ViewKind::ScrollV { .. }
377 ) {
378 s.align_items = Some(AlignItems::Stretch);
379 } else {
380 s.align_items = Some(AlignItems::FlexStart);
381 }
382 s.justify_content = Some(JustifyContent::FlexStart);
383
384 if let Some(r) = m.aspect_ratio {
385 s.aspect_ratio = Some(r);
386 }
387
388 if let Some(g) = m.flex_grow {
390 s.flex_grow = g;
391 }
392 if let Some(sh) = m.flex_shrink {
393 s.flex_shrink = sh;
394 }
395 if let Some(b) = m.flex_basis {
396 s.flex_basis = length(b);
397 }
398
399 if let Some(a) = m.align_self {
401 s.align_self = Some(a);
402 }
403
404 if let Some(repose_core::modifier::PositionType::Absolute) = m.position_type {
406 s.position = Position::Absolute;
407 s.inset = taffy::geometry::Rect {
408 left: m.offset_left.map(length).unwrap_or_else(auto),
409 right: m.offset_right.map(length).unwrap_or_else(auto),
410 top: m.offset_top.map(length).unwrap_or_else(auto),
411 bottom: m.offset_bottom.map(length).unwrap_or_else(auto),
412 };
413 }
414
415 if let Some(cfg) = &m.grid {
417 s.display = Display::Grid;
418
419 s.grid_template_columns = (0..cfg.columns)
421 .map(|_| GridTemplateComponent::Single(flex(1.0f32)))
422 .collect();
423
424 s.gap = Size {
426 width: length(cfg.column_gap),
427 height: length(cfg.row_gap),
428 };
429 }
430
431 if matches!(kind, ViewKind::ScrollV { .. }) {
433 s.overflow = taffy::Point {
434 x: Overflow::Hidden,
435 y: Overflow::Hidden,
436 };
437 }
438
439 if let Some(dir) = flex_dir_for(kind) {
440 s.flex_direction = dir;
441 }
442 if let Some(p) = m.padding {
443 let v = length(p);
444 s.padding = taffy::geometry::Rect {
445 left: v,
446 right: v,
447 top: v,
448 bottom: v,
449 };
450 }
451
452 if let Some(sz) = m.size {
453 if sz.width.is_finite() {
454 s.size.width = length(sz.width);
455 }
456 if sz.height.is_finite() {
457 s.size.height = length(sz.height);
458 }
459 }
460
461 if m.fill_max {
462 s.size.width = percent(1.0);
463 s.size.height = percent(1.0);
464 s.flex_grow = 1.0;
465 s.flex_shrink = 1.0;
466 }
467 s
468 }
469
470 fn build_node(
471 v: &View,
472 t: &mut TaffyTree<NodeCtx>,
473 nodes_map: &mut HashMap<ViewId, taffy::NodeId>,
474 ) -> taffy::NodeId {
475 let mut style = style_from_modifier(&v.modifier, &v.kind);
476
477 if v.modifier.grid_col_span.is_some() || v.modifier.grid_row_span.is_some() {
478 use taffy::prelude::{GridPlacement, Line};
479
480 let col_span = v.modifier.grid_col_span.unwrap_or(1).max(1);
481 let row_span = v.modifier.grid_row_span.unwrap_or(1).max(1);
482
483 style.grid_column = Line {
484 start: GridPlacement::Auto,
485 end: GridPlacement::Span(col_span),
486 };
487 style.grid_row = Line {
488 start: GridPlacement::Auto,
489 end: GridPlacement::Span(row_span),
490 };
491 }
492
493 let children: Vec<_> = v
494 .children
495 .iter()
496 .map(|c| build_node(c, t, nodes_map))
497 .collect();
498
499 let node = match &v.kind {
500 ViewKind::Text {
501 text, font_size, ..
502 } => t
503 .new_leaf_with_context(
504 style,
505 NodeCtx::Text {
506 text: text.clone(),
507 font_px: *font_size,
508 },
509 )
510 .unwrap(),
511 ViewKind::Button { text, .. } => t
512 .new_leaf_with_context(
513 style,
514 NodeCtx::Button {
515 label: text.clone(),
516 },
517 )
518 .unwrap(),
519 ViewKind::TextField { .. } => {
520 t.new_leaf_with_context(style, NodeCtx::TextField).unwrap()
521 }
522 ViewKind::Checkbox { label, .. } => t
523 .new_leaf_with_context(
524 style,
525 NodeCtx::Checkbox {
526 label: label.clone(),
527 },
528 )
529 .unwrap(),
530 ViewKind::RadioButton { label, .. } => t
531 .new_leaf_with_context(
532 style,
533 NodeCtx::Radio {
534 label: label.clone(),
535 },
536 )
537 .unwrap(),
538 ViewKind::Switch { label, .. } => t
539 .new_leaf_with_context(
540 style,
541 NodeCtx::Switch {
542 label: label.clone(),
543 },
544 )
545 .unwrap(),
546 ViewKind::Slider { label, .. } => t
547 .new_leaf_with_context(
548 style,
549 NodeCtx::Slider {
550 label: label.clone(),
551 },
552 )
553 .unwrap(),
554 ViewKind::RangeSlider { label, .. } => t
555 .new_leaf_with_context(
556 style,
557 NodeCtx::Range {
558 label: label.clone(),
559 },
560 )
561 .unwrap(),
562 ViewKind::ProgressBar { label, .. } => t
563 .new_leaf_with_context(
564 style,
565 NodeCtx::Progress {
566 label: label.clone(),
567 },
568 )
569 .unwrap(),
570 ViewKind::ScrollV { .. } => {
571 let children: Vec<_> = v
572 .children
573 .iter()
574 .map(|c| build_node(c, t, nodes_map))
575 .collect();
576
577 let n = t.new_with_children(style, &children).unwrap();
578 t.set_node_context(n, Some(NodeCtx::ScrollContainer)).ok();
579 n
580 }
581 _ => {
582 let n = t.new_with_children(style, &children).unwrap();
583 t.set_node_context(n, Some(NodeCtx::Container)).ok();
584 n
585 }
586 };
587
588 nodes_map.insert(v.id, node);
589 node
590 }
591
592 let root_node = build_node(&root, &mut taffy, &mut nodes_map);
593
594 let available = taffy::geometry::Size {
595 width: AvailableSpace::Definite(size.0 as f32),
596 height: AvailableSpace::Definite(size.1 as f32),
597 };
598
599 taffy
601 .compute_layout_with_measure(root_node, available, |known, avail, _node, ctx, _style| {
602 match ctx {
603 Some(NodeCtx::Text { text, font_px }) => {
604 let approx_w = text.len() as f32 * *font_px * 0.6;
605 let w = known.width.unwrap_or(approx_w);
606 taffy::geometry::Size {
607 width: w,
608 height: *font_px * 1.3,
609 }
610 }
611 Some(NodeCtx::Button { label }) => taffy::geometry::Size {
612 width: (label.len() as f32 * 16.0 * 0.6) + 24.0,
613 height: 36.0,
614 },
615 Some(NodeCtx::TextField) => {
616 let w = known.width.unwrap_or(220.0);
617 taffy::geometry::Size {
618 width: w,
619 height: 36.0,
620 }
621 }
622 Some(NodeCtx::Checkbox { label }) => {
623 let label_w = (label.len() as f32) * 16.0 * 0.6;
624 let w = 24.0 + 8.0 + label_w; taffy::geometry::Size {
626 width: known.width.unwrap_or(w),
627 height: 24.0,
628 }
629 }
630 Some(NodeCtx::Radio { label }) => {
631 let label_w = (label.len() as f32) * 16.0 * 0.6;
632 let w = 24.0 + 8.0 + label_w; taffy::geometry::Size {
634 width: known.width.unwrap_or(w),
635 height: 24.0,
636 }
637 }
638 Some(NodeCtx::Switch { label }) => {
639 let label_w = (label.len() as f32) * 16.0 * 0.6;
640 let w = 46.0 + 8.0 + label_w; taffy::geometry::Size {
642 width: known.width.unwrap_or(w),
643 height: 28.0,
644 }
645 }
646 Some(NodeCtx::Slider { label }) => {
647 let label_w = (label.len() as f32) * 16.0 * 0.6;
648 let w = (known.width).unwrap_or(200.0f32.max(46.0 + 8.0 + label_w));
649 taffy::geometry::Size {
650 width: w,
651 height: 28.0,
652 }
653 }
654 Some(NodeCtx::Range { label }) => {
655 let label_w = (label.len() as f32) * 16.0 * 0.6;
656 let w = (known.width).unwrap_or(220.0f32.max(46.0 + 8.0 + label_w));
657 taffy::geometry::Size {
658 width: w,
659 height: 28.0,
660 }
661 }
662 Some(NodeCtx::Progress { label }) => {
663 let label_w = (label.len() as f32) * 16.0 * 0.6;
664 let w = (known.width).unwrap_or(200.0f32.max(100.0 + 8.0 + label_w));
665 taffy::geometry::Size {
666 width: w,
667 height: 12.0 + 8.0,
668 } }
670 Some(NodeCtx::ScrollContainer) => {
671 taffy::geometry::Size {
672 width: known.width.unwrap_or_else(|| {
673 match avail.width {
674 AvailableSpace::Definite(w) => w,
675 _ => 300.0, }
677 }),
678 height: known.height.unwrap_or_else(|| {
679 match avail.height {
680 AvailableSpace::Definite(h) => h,
681 _ => 600.0, }
683 }),
684 }
685 }
686 Some(NodeCtx::Container) | None => taffy::geometry::Size::ZERO,
687 }
688 })
689 .unwrap();
690
691 fn layout_of(node: taffy::NodeId, t: &TaffyTree<impl Clone>) -> repose_core::Rect {
699 let l = t.layout(node).unwrap();
700 repose_core::Rect {
701 x: l.location.x,
702 y: l.location.y,
703 w: l.size.width,
704 h: l.size.height,
705 }
706 }
707
708 fn add_offset(mut r: repose_core::Rect, off: (f32, f32)) -> repose_core::Rect {
709 r.x += off.0;
710 r.y += off.1;
711 r
712 }
713
714 fn clamp01(x: f32) -> f32 {
715 x.max(0.0).min(1.0)
716 }
717 fn norm(value: f32, min: f32, max: f32) -> f32 {
718 if max > min {
719 (value - min) / (max - min)
720 } else {
721 0.0
722 }
723 }
724 fn denorm(t: f32, min: f32, max: f32) -> f32 {
725 min + t * (max - min)
726 }
727 fn snap_step(v: f32, step: Option<f32>, min: f32, max: f32) -> f32 {
728 match step {
729 Some(s) if s > 0.0 => {
730 let k = ((v - min) / s).round();
731 (min + k * s).clamp(min, max)
732 }
733 _ => v.clamp(min, max),
734 }
735 }
736
737 let mut scene = Scene {
738 clear_color: locals::theme().background,
739 nodes: vec![],
740 };
741 let mut hits: Vec<HitRegion> = vec![];
742 let mut sems: Vec<SemNode> = vec![];
743
744 fn walk(
745 v: &View,
746 t: &TaffyTree<NodeCtx>,
747 nodes: &HashMap<ViewId, taffy::NodeId>,
748 scene: &mut Scene,
749 hits: &mut Vec<HitRegion>,
750 sems: &mut Vec<SemNode>,
751 textfield_states: &HashMap<u64, Rc<RefCell<TextFieldState>>>,
752 interactions: &Interactions,
753 focused: Option<u64>,
754 parent_offset: (f32, f32),
755 ) {
756 let local = layout_of(nodes[&v.id], t);
757 let rect = add_offset(local, parent_offset);
758 let base = (parent_offset.0 + local.x, parent_offset.1 + local.y);
759
760 let is_hovered = interactions.hover == Some(v.id);
761 let is_pressed = interactions.pressed.contains(&v.id);
762 let is_focused = focused == Some(v.id);
763
764 if let Some(bg) = v.modifier.background {
766 scene.nodes.push(SceneNode::Rect {
767 rect,
768 color: bg,
769 radius: v.modifier.clip_rounded.unwrap_or(0.0),
770 });
771 }
772
773 if let Some(b) = &v.modifier.border {
775 scene.nodes.push(SceneNode::Border {
776 rect,
777 color: b.color,
778 width: b.width,
779 radius: b.radius.max(v.modifier.clip_rounded.unwrap_or(0.0)),
780 });
781 }
782
783 let has_pointer = v.modifier.on_pointer_down.is_some()
784 || v.modifier.on_pointer_move.is_some()
785 || v.modifier.on_pointer_up.is_some()
786 || v.modifier.on_pointer_enter.is_some()
787 || v.modifier.on_pointer_leave.is_some();
788
789 if has_pointer || v.modifier.click {
790 hits.push(HitRegion {
791 id: v.id,
792 rect,
793 on_click: None, on_scroll: None, focusable: false,
796 on_pointer_down: v.modifier.on_pointer_down.clone(),
797 on_pointer_move: v.modifier.on_pointer_move.clone(),
798 on_pointer_up: v.modifier.on_pointer_up.clone(),
799 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
800 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
801 z_index: v.modifier.z_index,
802 on_text_change: None,
803 on_text_submit: None,
804 });
805 }
806
807 match &v.kind {
808 ViewKind::Text {
809 text,
810 color,
811 font_size,
812 } => {
813 let scaled_size = *font_size * locals::text_scale().0;
815 scene.nodes.push(SceneNode::Text {
816 rect,
817 text: text.clone(),
818 color: *color,
819 size: scaled_size,
820 });
821 sems.push(SemNode {
822 id: v.id,
823 role: Role::Text,
824 label: Some(text.clone()),
825 rect,
826 focused: is_focused,
827 enabled: true,
828 });
829 }
830
831 ViewKind::Button { text, on_click } => {
832 if v.modifier.background.is_none() {
834 let base = if is_pressed {
835 Color::from_hex("#1f7556")
836 } else if is_hovered {
837 Color::from_hex("#2a8f6a")
838 } else {
839 Color::from_hex("#34af82")
840 };
841 scene.nodes.push(SceneNode::Rect {
842 rect,
843 color: base,
844 radius: v.modifier.clip_rounded.unwrap_or(6.0),
845 });
846 }
847 let px = 16.0;
849 let approx_w = (text.len() as f32) * px * 0.6;
850 let tx = rect.x + (rect.w - approx_w).max(0.0) * 0.5;
851 let ty = rect.y + (rect.h - px).max(0.0) * 0.5;
852 scene.nodes.push(SceneNode::Text {
853 rect: repose_core::Rect {
854 x: tx,
855 y: ty,
856 w: approx_w,
857 h: px,
858 },
859 text: text.clone(),
860 color: Color::WHITE,
861 size: px,
862 });
863
864 if v.modifier.click || on_click.is_some() {
865 hits.push(HitRegion {
866 id: v.id,
867 rect,
868 on_click: on_click.clone(),
869 on_scroll: None,
870 focusable: true,
871 on_pointer_down: v.modifier.on_pointer_down.clone(),
872 on_pointer_move: v.modifier.on_pointer_move.clone(),
873 on_pointer_up: v.modifier.on_pointer_up.clone(),
874 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
875 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
876 z_index: v.modifier.z_index,
877 on_text_change: None,
878 on_text_submit: None,
879 });
880 }
881 sems.push(SemNode {
882 id: v.id,
883 role: Role::Button,
884 label: Some(text.clone()),
885 rect,
886 focused: is_focused,
887 enabled: true,
888 });
889 if is_focused {
891 scene.nodes.push(SceneNode::Border {
892 rect,
893 color: Color::from_hex("#88CCFF"),
894 width: 2.0,
895 radius: v.modifier.clip_rounded.unwrap_or(6.0),
896 });
897 }
898 }
899
900 ViewKind::TextField {
901 hint,
902 on_change,
903 on_submit,
904 ..
905 } => {
906 hits.push(HitRegion {
907 id: v.id,
908 rect,
909 on_click: None,
910 on_scroll: None,
911 focusable: true,
912 on_pointer_down: None,
913 on_pointer_move: None,
914 on_pointer_up: None,
915 on_pointer_enter: None,
916 on_pointer_leave: None,
917 z_index: v.modifier.z_index,
918 on_text_change: on_change.clone(),
919 on_text_submit: on_submit.clone(),
920 });
921
922 let inner = repose_core::Rect {
924 x: rect.x + TF_PADDING_X,
925 y: rect.y + 8.0,
926 w: rect.w - 2.0 * TF_PADDING_X,
927 h: rect.h - 16.0,
928 };
929 scene.nodes.push(SceneNode::PushClip {
930 rect: inner,
931 radius: 0.0,
932 });
933 if is_focused {
935 scene.nodes.push(SceneNode::Border {
936 rect,
937 color: Color::from_hex("#88CCFF"),
938 width: 2.0,
939 radius: v.modifier.clip_rounded.unwrap_or(6.0),
940 });
941 }
942 if let Some(state_rc) = textfield_states.get(&v.id) {
943 state_rc.borrow_mut().set_inner_width(inner.w);
944
945 let state = state_rc.borrow();
946 let text = &state.text;
947 let px = TF_FONT_PX as u32;
948 let m = measure_text(text, px);
949
950 if state.selection.start != state.selection.end {
952 let i0 = byte_to_char_index(&m, state.selection.start);
953 let i1 = byte_to_char_index(&m, state.selection.end);
954 let sx = m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
955 let ex = m.positions.get(i1).copied().unwrap_or(sx) - state.scroll_offset;
956 let sel_x = inner.x + sx.max(0.0);
957 let sel_w = (ex - sx).max(0.0);
958 scene.nodes.push(SceneNode::Rect {
959 rect: repose_core::Rect {
960 x: sel_x,
961 y: inner.y,
962 w: sel_w,
963 h: inner.h,
964 },
965 color: Color::from_hex("#3B7BFF55"),
966 radius: 0.0,
967 });
968 }
969
970 if let Some(range) = &state.composition {
972 if range.start < range.end && !text.is_empty() {
973 let i0 = byte_to_char_index(&m, range.start);
974 let i1 = byte_to_char_index(&m, range.end);
975 let sx =
976 m.positions.get(i0).copied().unwrap_or(0.0) - state.scroll_offset;
977 let ex =
978 m.positions.get(i1).copied().unwrap_or(sx) - state.scroll_offset;
979 let ux = inner.x + sx.max(0.0);
980 let uw = (ex - sx).max(0.0);
981 scene.nodes.push(SceneNode::Rect {
982 rect: repose_core::Rect {
983 x: ux,
984 y: inner.y + inner.h - 2.0,
985 w: uw,
986 h: 2.0,
987 },
988 color: Color::from_hex("#88CCFF"),
989 radius: 0.0,
990 });
991 }
992 }
993
994 scene.nodes.push(SceneNode::Text {
996 rect: repose_core::Rect {
997 x: inner.x - state.scroll_offset,
998 y: inner.y,
999 w: inner.w,
1000 h: inner.h,
1001 },
1002 text: if text.is_empty() {
1003 hint.clone()
1004 } else {
1005 text.clone()
1006 },
1007 color: if text.is_empty() {
1008 Color::from_hex("#666666")
1009 } else {
1010 Color::from_hex("#CCCCCC")
1011 },
1012 size: TF_FONT_PX,
1013 });
1014
1015 if state.selection.start == state.selection.end && state.caret_visible() {
1017 let i = byte_to_char_index(&m, state.selection.end);
1018 let cx = m.positions.get(i).copied().unwrap_or(0.0) - state.scroll_offset;
1019 let caret_x = inner.x + cx.max(0.0);
1020 scene.nodes.push(SceneNode::Rect {
1021 rect: repose_core::Rect {
1022 x: caret_x,
1023 y: inner.y,
1024 w: 1.0,
1025 h: inner.h,
1026 },
1027 color: Color::WHITE,
1028 radius: 0.0,
1029 });
1030 }
1031 scene.nodes.push(SceneNode::PopClip);
1033
1034 sems.push(SemNode {
1035 id: v.id,
1036 role: Role::TextField,
1037 label: Some(text.clone()),
1038 rect,
1039 focused: is_focused,
1040 enabled: true,
1041 });
1042 } else {
1043 scene.nodes.push(SceneNode::Text {
1045 rect: repose_core::Rect {
1046 x: inner.x,
1047 y: inner.y,
1048 w: inner.w,
1049 h: inner.h,
1050 },
1051 text: hint.clone(),
1052 color: Color::from_hex("#666666"),
1053 size: TF_FONT_PX,
1054 });
1055 sems.push(SemNode {
1056 id: v.id,
1057 role: Role::TextField,
1058 label: Some(hint.clone()),
1059 rect,
1060 focused: is_focused,
1061 enabled: true,
1062 });
1063 }
1064 }
1065 ViewKind::ScrollV {
1066 on_scroll,
1067 set_viewport_height,
1068 get_scroll_offset,
1069 } => {
1070 log::debug!("ScrollV: registering hit region at rect {:?}", rect);
1071
1072 hits.push(HitRegion {
1074 id: v.id,
1075 rect, on_click: None,
1077 on_scroll: on_scroll.clone(),
1078 focusable: false,
1079 on_pointer_down: v.modifier.on_pointer_down.clone(),
1080 on_pointer_move: v.modifier.on_pointer_move.clone(),
1081 on_pointer_up: v.modifier.on_pointer_up.clone(),
1082 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1083 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1084 z_index: v.modifier.z_index,
1085 on_text_change: None,
1086 on_text_submit: None,
1087 });
1088
1089 let scroll_offset = if let Some(get) = get_scroll_offset {
1091 let offset = get();
1092 log::debug!("ScrollV walk: applying scroll offset = {}", offset);
1093 offset
1094 } else {
1095 0.0
1096 };
1097
1098 scene.nodes.push(SceneNode::PushClip {
1100 rect,
1101 radius: v.modifier.clip_rounded.unwrap_or(0.0),
1102 });
1103
1104 let child_offset = (base.0, base.1 - scroll_offset); log::debug!(
1107 "ScrollV walk: base={:?}, child_offset={:?}",
1108 base,
1109 child_offset
1110 );
1111
1112 for c in &v.children {
1113 walk(
1114 c,
1115 t,
1116 nodes,
1117 scene,
1118 hits,
1119 sems,
1120 textfield_states,
1121 interactions,
1122 focused,
1123 child_offset,
1124 );
1125 }
1126
1127 scene.nodes.push(SceneNode::PopClip);
1128 return;
1129 }
1130
1131 ViewKind::Checkbox {
1132 checked,
1133 label,
1134 on_change,
1135 } => {
1136 let theme = locals::theme();
1137 let box_size = 18.0f32;
1139 let bx = rect.x;
1140 let by = rect.y + (rect.h - box_size) * 0.5;
1141 scene.nodes.push(SceneNode::Rect {
1143 rect: repose_core::Rect {
1144 x: bx,
1145 y: by,
1146 w: box_size,
1147 h: box_size,
1148 },
1149 color: if *checked {
1150 theme.primary
1151 } else {
1152 theme.surface
1153 },
1154 radius: 3.0,
1155 });
1156 scene.nodes.push(SceneNode::Border {
1157 rect: repose_core::Rect {
1158 x: bx,
1159 y: by,
1160 w: box_size,
1161 h: box_size,
1162 },
1163 color: Color::from_hex("#555555"),
1164 width: 1.0,
1165 radius: 3.0,
1166 });
1167 if *checked {
1169 scene.nodes.push(SceneNode::Text {
1170 rect: repose_core::Rect {
1171 x: bx + 3.0,
1172 y: by + 1.0,
1173 w: box_size,
1174 h: box_size,
1175 },
1176 text: "✓".to_string(),
1177 color: theme.on_primary,
1178 size: 16.0,
1179 });
1180 }
1181 scene.nodes.push(SceneNode::Text {
1183 rect: repose_core::Rect {
1184 x: bx + box_size + 8.0,
1185 y: rect.y,
1186 w: rect.w - (box_size + 8.0),
1187 h: rect.h,
1188 },
1189 text: label.clone(),
1190 color: theme.on_surface,
1191 size: 16.0,
1192 });
1193
1194 let toggled = !*checked;
1196 let on_click = on_change.as_ref().map(|cb| {
1197 let cb = cb.clone();
1198 Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
1199 });
1200 hits.push(HitRegion {
1201 id: v.id,
1202 rect,
1203 on_click,
1204 on_scroll: None,
1205 focusable: true,
1206 on_pointer_down: v.modifier.on_pointer_down.clone(),
1207 on_pointer_move: v.modifier.on_pointer_move.clone(),
1208 on_pointer_up: v.modifier.on_pointer_up.clone(),
1209 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1210 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1211 z_index: v.modifier.z_index,
1212 on_text_change: None,
1213 on_text_submit: None,
1214 });
1215 sems.push(SemNode {
1216 id: v.id,
1217 role: Role::Checkbox,
1218 label: Some(label.clone()),
1219 rect,
1220 focused: is_focused,
1221 enabled: true,
1222 });
1223 if is_focused {
1224 scene.nodes.push(SceneNode::Border {
1225 rect,
1226 color: Color::from_hex("#88CCFF"),
1227 width: 2.0,
1228 radius: v.modifier.clip_rounded.unwrap_or(6.0),
1229 });
1230 }
1231 }
1232
1233 ViewKind::RadioButton {
1234 selected,
1235 label,
1236 on_select,
1237 } => {
1238 let theme = locals::theme();
1239 let d = 18.0f32;
1240 let cx = rect.x;
1241 let cy = rect.y + (rect.h - d) * 0.5;
1242
1243 scene.nodes.push(SceneNode::Border {
1245 rect: repose_core::Rect {
1246 x: cx,
1247 y: cy,
1248 w: d,
1249 h: d,
1250 },
1251 color: Color::from_hex("#888888"),
1252 width: 1.5,
1253 radius: d * 0.5,
1254 });
1255 if *selected {
1257 scene.nodes.push(SceneNode::Rect {
1258 rect: repose_core::Rect {
1259 x: cx + 4.0,
1260 y: cy + 4.0,
1261 w: d - 8.0,
1262 h: d - 8.0,
1263 },
1264 color: theme.primary,
1265 radius: (d - 8.0) * 0.5,
1266 });
1267 }
1268 scene.nodes.push(SceneNode::Text {
1269 rect: repose_core::Rect {
1270 x: cx + d + 8.0,
1271 y: rect.y,
1272 w: rect.w - (d + 8.0),
1273 h: rect.h,
1274 },
1275 text: label.clone(),
1276 color: theme.on_surface,
1277 size: 16.0,
1278 });
1279
1280 hits.push(HitRegion {
1281 id: v.id,
1282 rect,
1283 on_click: on_select.clone(),
1284 on_scroll: None,
1285 focusable: true,
1286 on_pointer_down: v.modifier.on_pointer_down.clone(),
1287 on_pointer_move: v.modifier.on_pointer_move.clone(),
1288 on_pointer_up: v.modifier.on_pointer_up.clone(),
1289 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1290 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1291 z_index: v.modifier.z_index,
1292 on_text_change: None,
1293 on_text_submit: None,
1294 });
1295 sems.push(SemNode {
1296 id: v.id,
1297 role: Role::RadioButton,
1298 label: Some(label.clone()),
1299 rect,
1300 focused: is_focused,
1301 enabled: true,
1302 });
1303 if is_focused {
1304 scene.nodes.push(SceneNode::Border {
1305 rect,
1306 color: Color::from_hex("#88CCFF"),
1307 width: 2.0,
1308 radius: v.modifier.clip_rounded.unwrap_or(6.0),
1309 });
1310 }
1311 }
1312
1313 ViewKind::Switch {
1314 checked,
1315 label,
1316 on_change,
1317 } => {
1318 let theme = locals::theme();
1319 let track_w = 46.0f32;
1321 let track_h = 26.0f32;
1322 let tx = rect.x;
1323 let ty = rect.y + (rect.h - track_h) * 0.5;
1324 let knob = 22.0f32;
1325 let on_col = theme.primary;
1326 let off_col = Color::from_hex("#333333");
1327
1328 scene.nodes.push(SceneNode::Rect {
1330 rect: repose_core::Rect {
1331 x: tx,
1332 y: ty,
1333 w: track_w,
1334 h: track_h,
1335 },
1336 color: if *checked { on_col } else { off_col },
1337 radius: track_h * 0.5,
1338 });
1339 let kx = if *checked {
1341 tx + track_w - knob - 2.0
1342 } else {
1343 tx + 2.0
1344 };
1345 let ky = ty + (track_h - knob) * 0.5;
1346 scene.nodes.push(SceneNode::Rect {
1347 rect: repose_core::Rect {
1348 x: kx,
1349 y: ky,
1350 w: knob,
1351 h: knob,
1352 },
1353 color: Color::from_hex("#EEEEEE"),
1354 radius: knob * 0.5,
1355 });
1356
1357 scene.nodes.push(SceneNode::Text {
1359 rect: repose_core::Rect {
1360 x: tx + track_w + 8.0,
1361 y: rect.y,
1362 w: rect.w - (track_w + 8.0),
1363 h: rect.h,
1364 },
1365 text: label.clone(),
1366 color: theme.on_surface,
1367 size: 16.0,
1368 });
1369
1370 let toggled = !*checked;
1371 let on_click = on_change.as_ref().map(|cb| {
1372 let cb = cb.clone();
1373 Rc::new(move || cb(toggled)) as Rc<dyn Fn()>
1374 });
1375 hits.push(HitRegion {
1376 id: v.id,
1377 rect,
1378 on_click,
1379 on_scroll: None,
1380 focusable: true,
1381 on_pointer_down: v.modifier.on_pointer_down.clone(),
1382 on_pointer_move: v.modifier.on_pointer_move.clone(),
1383 on_pointer_up: v.modifier.on_pointer_up.clone(),
1384 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1385 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1386 z_index: v.modifier.z_index,
1387 on_text_change: None,
1388 on_text_submit: None,
1389 });
1390 sems.push(SemNode {
1391 id: v.id,
1392 role: Role::Switch,
1393 label: Some(label.clone()),
1394 rect,
1395 focused: is_focused,
1396 enabled: true,
1397 });
1398 if is_focused {
1399 scene.nodes.push(SceneNode::Border {
1400 rect,
1401 color: Color::from_hex("#88CCFF"),
1402 width: 2.0,
1403 radius: v.modifier.clip_rounded.unwrap_or(6.0),
1404 });
1405 }
1406 }
1407 ViewKind::Slider {
1408 value,
1409 min,
1410 max,
1411 step,
1412 label,
1413 on_change,
1414 } => {
1415 let theme = locals::theme();
1416 let track_h = 4.0f32;
1418 let knob_d = 20.0f32;
1419 let gap = 8.0f32;
1420 let label_x = rect.x + rect.w * 0.6; let track_x = rect.x;
1422 let track_w = (label_x - track_x).max(60.0);
1423 let cy = rect.y + rect.h * 0.5;
1424
1425 scene.nodes.push(SceneNode::Rect {
1427 rect: repose_core::Rect {
1428 x: track_x,
1429 y: cy - track_h * 0.5,
1430 w: track_w,
1431 h: track_h,
1432 },
1433 color: Color::from_hex("#333333"),
1434 radius: track_h * 0.5,
1435 });
1436
1437 let t = clamp01(norm(*value, *min, *max));
1439 let kx = track_x + t * track_w;
1440 scene.nodes.push(SceneNode::Rect {
1441 rect: repose_core::Rect {
1442 x: kx - knob_d * 0.5,
1443 y: cy - knob_d * 0.5,
1444 w: knob_d,
1445 h: knob_d,
1446 },
1447 color: theme.surface,
1448 radius: knob_d * 0.5,
1449 });
1450 scene.nodes.push(SceneNode::Border {
1451 rect: repose_core::Rect {
1452 x: kx - knob_d * 0.5,
1453 y: cy - knob_d * 0.5,
1454 w: knob_d,
1455 h: knob_d,
1456 },
1457 color: Color::from_hex("#888888"),
1458 width: 1.0,
1459 radius: knob_d * 0.5,
1460 });
1461
1462 scene.nodes.push(SceneNode::Text {
1464 rect: repose_core::Rect {
1465 x: label_x + gap,
1466 y: rect.y,
1467 w: rect.x + rect.w - (label_x + gap),
1468 h: rect.h,
1469 },
1470 text: format!("{}: {:.2}", label, *value),
1471 color: theme.on_surface,
1472 size: 16.0,
1473 });
1474
1475 let on_change_cb: Option<Rc<dyn Fn(f32)>> = on_change.as_ref().cloned();
1477 let minv = *min;
1478 let maxv = *max;
1479 let stepv = *step;
1480
1481 let current = Rc::new(RefCell::new(*value));
1483
1484 let update_at = {
1486 let on_change_cb = on_change_cb.clone();
1487 let current = current.clone();
1488 Rc::new(move |px: f32| {
1489 let tt = clamp01((px - track_x) / track_w);
1490 let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
1491 *current.borrow_mut() = v;
1492 if let Some(cb) = &on_change_cb {
1493 cb(v);
1494 }
1495 })
1496 };
1497
1498 let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1500 let f = update_at.clone();
1501 Rc::new(move |pe| {
1502 f(pe.position.x);
1503 })
1504 };
1505
1506 let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1508 let f = update_at.clone();
1509 Rc::new(move |pe| {
1510 f(pe.position.x);
1511 })
1512 };
1513
1514 let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = Rc::new(move |_pe| {});
1516
1517 let on_scroll = {
1519 let on_change_cb = on_change_cb.clone();
1520 let current = current.clone();
1521 Rc::new(move |dy: f32| -> f32 {
1522 let base = *current.borrow();
1523 let delta = stepv.unwrap_or((maxv - minv) * 0.01);
1524 let dir = if dy.is_sign_negative() { 1.0 } else { -1.0 };
1526 let new_v = snap_step(base + dir * delta, stepv, minv, maxv);
1527 *current.borrow_mut() = new_v;
1528 if let Some(cb) = &on_change_cb {
1529 cb(new_v);
1530 }
1531 0.0
1532 })
1533 };
1534
1535 hits.push(HitRegion {
1537 id: v.id,
1538 rect,
1539 on_click: None,
1540 on_scroll: Some(on_scroll),
1541 focusable: true,
1542 on_pointer_down: Some(on_pd),
1543 on_pointer_move: if is_pressed { Some(on_pm) } else { None },
1544 on_pointer_up: Some(on_pu),
1545 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1546 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1547 z_index: v.modifier.z_index,
1548 on_text_change: None,
1549 on_text_submit: None,
1550 });
1551
1552 sems.push(SemNode {
1553 id: v.id,
1554 role: Role::Slider,
1555 label: Some(label.clone()),
1556 rect,
1557 focused: is_focused,
1558 enabled: true,
1559 });
1560 if is_focused {
1561 scene.nodes.push(SceneNode::Border {
1562 rect,
1563 color: Color::from_hex("#88CCFF"),
1564 width: 2.0,
1565 radius: v.modifier.clip_rounded.unwrap_or(6.0),
1566 });
1567 }
1568 }
1569 ViewKind::RangeSlider {
1570 start,
1571 end,
1572 min,
1573 max,
1574 step,
1575 label,
1576 on_change,
1577 } => {
1578 let theme = locals::theme();
1579 let track_h = 4.0f32;
1580 let knob_d = 20.0f32;
1581 let gap = 8.0f32;
1582 let label_x = rect.x + rect.w * 0.6;
1583 let track_x = rect.x;
1584 let track_w = (label_x - track_x).max(80.0);
1585 let cy = rect.y + rect.h * 0.5;
1586
1587 scene.nodes.push(SceneNode::Rect {
1589 rect: repose_core::Rect {
1590 x: track_x,
1591 y: cy - track_h * 0.5,
1592 w: track_w,
1593 h: track_h,
1594 },
1595 color: Color::from_hex("#333333"),
1596 radius: track_h * 0.5,
1597 });
1598
1599 let t0 = clamp01(norm(*start, *min, *max));
1601 let t1 = clamp01(norm(*end, *min, *max));
1602 let k0x = track_x + t0 * track_w;
1603 let k1x = track_x + t1 * track_w;
1604
1605 scene.nodes.push(SceneNode::Rect {
1607 rect: repose_core::Rect {
1608 x: k0x.min(k1x),
1609 y: cy - track_h * 0.5,
1610 w: (k1x - k0x).abs(),
1611 h: track_h,
1612 },
1613 color: theme.primary,
1614 radius: track_h * 0.5,
1615 });
1616
1617 for &kx in &[k0x, k1x] {
1619 scene.nodes.push(SceneNode::Rect {
1620 rect: repose_core::Rect {
1621 x: kx - knob_d * 0.5,
1622 y: cy - knob_d * 0.5,
1623 w: knob_d,
1624 h: knob_d,
1625 },
1626 color: theme.surface,
1627 radius: knob_d * 0.5,
1628 });
1629 scene.nodes.push(SceneNode::Border {
1630 rect: repose_core::Rect {
1631 x: kx - knob_d * 0.5,
1632 y: cy - knob_d * 0.5,
1633 w: knob_d,
1634 h: knob_d,
1635 },
1636 color: Color::from_hex("#888888"),
1637 width: 1.0,
1638 radius: knob_d * 0.5,
1639 });
1640 }
1641
1642 scene.nodes.push(SceneNode::Text {
1644 rect: repose_core::Rect {
1645 x: label_x + gap,
1646 y: rect.y,
1647 w: rect.x + rect.w - (label_x + gap),
1648 h: rect.h,
1649 },
1650 text: format!("{}: {:.2} – {:.2}", label, *start, *end),
1651 color: theme.on_surface,
1652 size: 16.0,
1653 });
1654
1655 let on_change_cb = on_change.as_ref().cloned();
1657 let minv = *min;
1658 let maxv = *max;
1659 let stepv = *step;
1660 let start_val = *start;
1661 let end_val = *end;
1662
1663 let active = Rc::new(RefCell::new(None::<u8>));
1665
1666 let update = {
1668 let active = active.clone();
1669 let on_change_cb = on_change_cb.clone();
1670 Rc::new(move |px: f32| {
1671 if let Some(thumb) = *active.borrow() {
1672 let tt = clamp01((px - track_x) / track_w);
1673 let v = snap_step(denorm(tt, minv, maxv), stepv, minv, maxv);
1674 match thumb {
1675 0 => {
1676 let new_start = v.min(end_val).min(maxv).max(minv);
1677 if let Some(cb) = &on_change_cb {
1678 cb(new_start, end_val);
1679 }
1680 }
1681 _ => {
1682 let new_end = v.max(start_val).max(minv).min(maxv);
1683 if let Some(cb) = &on_change_cb {
1684 cb(start_val, new_end);
1685 }
1686 }
1687 }
1688 }
1689 })
1690 };
1691
1692 let on_pd: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1694 let active = active.clone();
1695 let update = update.clone();
1696 let k0x0 = k0x;
1698 let k1x0 = k1x;
1699 Rc::new(move |pe| {
1700 let px = pe.position.x;
1701 let d0 = (px - k0x0).abs();
1702 let d1 = (px - k1x0).abs();
1703 *active.borrow_mut() = Some(if d0 <= d1 { 0 } else { 1 });
1704 update(px);
1705 })
1706 };
1707
1708 let on_pm: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1710 let active = active.clone();
1711 let update = update.clone();
1712 Rc::new(move |pe| {
1713 if active.borrow().is_some() {
1714 update(pe.position.x);
1715 }
1716 })
1717 };
1718
1719 let on_pu: Rc<dyn Fn(repose_core::input::PointerEvent)> = {
1721 let active = active.clone();
1722 Rc::new(move |_pe| {
1723 *active.borrow_mut() = None;
1724 })
1725 };
1726
1727 hits.push(HitRegion {
1728 id: v.id,
1729 rect,
1730 on_click: None,
1731 on_scroll: None,
1732 focusable: true,
1733 on_pointer_down: Some(on_pd),
1734 on_pointer_move: Some(on_pm),
1735 on_pointer_up: Some(on_pu),
1736 on_pointer_enter: v.modifier.on_pointer_enter.clone(),
1737 on_pointer_leave: v.modifier.on_pointer_leave.clone(),
1738 z_index: v.modifier.z_index,
1739 on_text_change: None,
1740 on_text_submit: None,
1741 });
1742 sems.push(SemNode {
1743 id: v.id,
1744 role: Role::Slider,
1745 label: Some(label.clone()),
1746 rect,
1747 focused: is_focused,
1748 enabled: true,
1749 });
1750 if is_focused {
1751 scene.nodes.push(SceneNode::Border {
1752 rect,
1753 color: Color::from_hex("#88CCFF"),
1754 width: 2.0,
1755 radius: v.modifier.clip_rounded.unwrap_or(6.0),
1756 });
1757 }
1758 }
1759 ViewKind::ProgressBar {
1760 value,
1761 min,
1762 max,
1763 label,
1764 circular,
1765 } => {
1766 let theme = locals::theme();
1767 let track_h = 6.0f32;
1768 let gap = 8.0f32;
1769 let label_w_split = rect.w * 0.6;
1770 let track_x = rect.x;
1771 let track_w = (label_w_split - track_x).max(60.0);
1772 let cy = rect.y + rect.h * 0.5;
1773
1774 scene.nodes.push(SceneNode::Rect {
1775 rect: repose_core::Rect {
1776 x: track_x,
1777 y: cy - track_h * 0.5,
1778 w: track_w,
1779 h: track_h,
1780 },
1781 color: Color::from_hex("#333333"),
1782 radius: track_h * 0.5,
1783 });
1784
1785 let t = clamp01(norm(*value, *min, *max));
1786 scene.nodes.push(SceneNode::Rect {
1787 rect: repose_core::Rect {
1788 x: track_x,
1789 y: cy - track_h * 0.5,
1790 w: track_w * t,
1791 h: track_h,
1792 },
1793 color: theme.primary,
1794 radius: track_h * 0.5,
1795 });
1796
1797 scene.nodes.push(SceneNode::Text {
1798 rect: repose_core::Rect {
1799 x: rect.x + label_w_split + gap,
1800 y: rect.y,
1801 w: rect.w - (label_w_split + gap),
1802 h: rect.h,
1803 },
1804 text: format!("{}: {:.0}%", label, t * 100.0),
1805 color: theme.on_surface,
1806 size: 16.0,
1807 });
1808
1809 sems.push(SemNode {
1810 id: v.id,
1811 role: Role::ProgressBar,
1812 label: Some(label.clone()),
1813 rect,
1814 focused: is_focused,
1815 enabled: true,
1816 });
1817 }
1818
1819 _ => {}
1820 }
1821
1822 for c in &v.children {
1824 walk(
1825 c,
1826 t,
1827 nodes,
1828 scene,
1829 hits,
1830 sems,
1831 textfield_states,
1832 interactions,
1833 focused,
1834 base,
1835 );
1836 }
1837 }
1838
1839 walk(
1841 &root,
1842 &taffy,
1843 &nodes_map,
1844 &mut scene,
1845 &mut hits,
1846 &mut sems,
1847 textfield_states,
1848 interactions,
1849 focused,
1850 (0.0, 0.0),
1851 );
1852
1853 hits.sort_by(|a, b| a.z_index.partial_cmp(&b.z_index).unwrap_or(Ordering::Equal));
1855
1856 (scene, hits, sems)
1857}