1use std::cell::RefCell;
35use std::sync::Arc;
36
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::scroll::{ScrollAlignment, ScrollRequest};
40use crate::state::{UiState, VirtualAnchor};
41use crate::text::metrics as text_metrics;
42use crate::tree::*;
43
44#[derive(Clone)]
74pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
75
76impl LayoutFn {
77 pub fn new<F>(f: F) -> Self
78 where
79 F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
80 {
81 LayoutFn(Arc::new(f))
82 }
83}
84
85impl std::fmt::Debug for LayoutFn {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str("LayoutFn(<fn>)")
88 }
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct LayoutIntrinsicCacheStats {
93 pub hits: u64,
94 pub misses: u64,
95}
96
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub struct LayoutPruneStats {
99 pub subtrees: u64,
100 pub nodes: u64,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Hash)]
104struct IntrinsicCacheKey {
105 computed_id: String,
106 available_width_bits: Option<u32>,
107}
108
109#[derive(Default)]
110struct IntrinsicCache {
111 measurements: FxHashMap<IntrinsicCacheKey, (f32, f32)>,
112 stats: LayoutIntrinsicCacheStats,
113 prune: LayoutPruneStats,
114}
115
116thread_local! {
117 static INTRINSIC_CACHE: RefCell<Option<IntrinsicCache>> = const { RefCell::new(None) };
118 static LAST_INTRINSIC_CACHE_STATS: RefCell<LayoutIntrinsicCacheStats> =
119 const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
120 static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
121 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
122}
123
124struct IntrinsicCacheGuard {
125 previous: Option<IntrinsicCache>,
126}
127
128impl Drop for IntrinsicCacheGuard {
129 fn drop(&mut self) {
130 INTRINSIC_CACHE.with(|cell| {
131 cell.replace(self.previous.take());
132 });
133 }
134}
135
136fn with_intrinsic_cache(f: impl FnOnce()) {
137 let previous = INTRINSIC_CACHE.with(|cell| cell.replace(Some(IntrinsicCache::default())));
138 let mut guard = IntrinsicCacheGuard { previous };
139 f();
140 let finished = INTRINSIC_CACHE.with(|cell| cell.replace(guard.previous.take()));
141 if let Some(cache) = finished {
142 LAST_INTRINSIC_CACHE_STATS.with(|stats| {
143 *stats.borrow_mut() = cache.stats;
144 });
145 LAST_PRUNE_STATS.with(|stats| {
146 *stats.borrow_mut() = cache.prune;
147 });
148 }
149 std::mem::forget(guard);
150}
151
152pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
153 LAST_INTRINSIC_CACHE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
154}
155
156pub fn take_prune_stats() -> LayoutPruneStats {
157 LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
158}
159
160#[derive(Clone, Debug)]
183pub enum VirtualMode {
184 Fixed { row_height: f32 },
186 Dynamic { estimated_row_height: f32 },
190}
191
192#[derive(Clone, Copy, Debug, PartialEq)]
196pub enum VirtualAnchorPolicy {
197 ViewportFraction { y_fraction: f32 },
200 FirstVisible,
203 LastVisible,
206}
207
208impl Default for VirtualAnchorPolicy {
209 fn default() -> Self {
210 Self::ViewportFraction { y_fraction: 0.25 }
211 }
212}
213
214#[derive(Clone)]
215#[non_exhaustive]
216pub struct VirtualItems {
217 pub count: usize,
218 pub mode: VirtualMode,
219 pub anchor_policy: VirtualAnchorPolicy,
220 pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
221 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
222}
223
224impl VirtualItems {
225 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
226 where
227 F: Fn(usize) -> El + Send + Sync + 'static,
228 {
229 assert!(
230 row_height > 0.0,
231 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
232 );
233 VirtualItems {
234 count,
235 mode: VirtualMode::Fixed { row_height },
236 anchor_policy: VirtualAnchorPolicy::default(),
237 row_key: Arc::new(|i| i.to_string()),
238 build_row: Arc::new(build_row),
239 }
240 }
241
242 pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
243 where
244 K: Fn(usize) -> String + Send + Sync + 'static,
245 F: Fn(usize) -> El + Send + Sync + 'static,
246 {
247 assert!(
248 estimated_row_height > 0.0,
249 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
250 );
251 VirtualItems {
252 count,
253 mode: VirtualMode::Dynamic {
254 estimated_row_height,
255 },
256 anchor_policy: VirtualAnchorPolicy::default(),
257 row_key: Arc::new(row_key),
258 build_row: Arc::new(build_row),
259 }
260 }
261
262 pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
263 self.anchor_policy = policy;
264 self
265 }
266}
267
268impl std::fmt::Debug for VirtualItems {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.debug_struct("VirtualItems")
271 .field("count", &self.count)
272 .field("mode", &self.mode)
273 .field("anchor_policy", &self.anchor_policy)
274 .field("row_key", &"<fn>")
275 .field("build_row", &"<fn>")
276 .finish()
277 }
278}
279
280#[non_exhaustive]
285pub struct LayoutCtx<'a> {
286 pub container: Rect,
290 pub children: &'a [El],
293 pub measure: &'a dyn Fn(&El) -> (f32, f32),
297 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
304 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
310}
311
312pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
321 {
322 crate::profile_span!("layout::assign_ids");
323 assign_id(root, "root");
324 }
325 layout_post_assign(root, ui_state, viewport);
326}
327
328pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
334 with_intrinsic_cache(|| {
335 {
336 crate::profile_span!("layout::root_setup");
337 ui_state
338 .layout
339 .computed_rects
340 .insert(root.computed_id.clone(), viewport);
341 rebuild_key_index(root, ui_state);
342 ui_state.scroll.metrics.clear();
346 ui_state.scroll.thumb_rects.clear();
347 ui_state.scroll.thumb_tracks.clear();
348 }
349 crate::profile_span!("layout::children");
350 layout_children(root, viewport, ui_state);
351 });
352}
353
354pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
361 let role = role_token(&child.kind);
362 let suffix = match (&child.key, role) {
363 (Some(k), r) => format!("{r}[{k}]"),
364 (None, r) => format!("{r}.{child_index}"),
365 };
366 assign_id(child, &format!("{parent_id}.{suffix}"));
367}
368
369fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
374 ui_state.layout.key_index.clear();
375 fn visit(node: &El, index: &mut rustc_hash::FxHashMap<String, String>) {
376 if let Some(key) = &node.key {
377 index
378 .entry(key.clone())
379 .or_insert_with(|| node.computed_id.clone());
380 }
381 for c in &node.children {
382 visit(c, index);
383 }
384 }
385 visit(root, &mut ui_state.layout.key_index);
386}
387
388pub fn assign_ids(root: &mut El) {
392 assign_id(root, "root");
393}
394
395fn assign_id(node: &mut El, path: &str) {
396 node.computed_id = path.to_string();
397 for (i, c) in node.children.iter_mut().enumerate() {
398 let role = role_token(&c.kind);
399 let suffix = match (&c.key, role) {
400 (Some(k), r) => format!("{r}[{k}]"),
401 (None, r) => format!("{r}.{i}"),
402 };
403 let child_path = format!("{path}.{suffix}");
404 assign_id(c, &child_path);
405 }
406}
407
408fn role_token(k: &Kind) -> &'static str {
409 match k {
410 Kind::Group => "group",
411 Kind::Card => "card",
412 Kind::Button => "button",
413 Kind::Badge => "badge",
414 Kind::Text => "text",
415 Kind::Heading => "heading",
416 Kind::Spacer => "spacer",
417 Kind::Divider => "divider",
418 Kind::Overlay => "overlay",
419 Kind::Scrim => "scrim",
420 Kind::Modal => "modal",
421 Kind::Scroll => "scroll",
422 Kind::VirtualList => "virtual_list",
423 Kind::Inlines => "inlines",
424 Kind::HardBreak => "hard_break",
425 Kind::Math => "math",
426 Kind::Image => "image",
427 Kind::Surface => "surface",
428 Kind::Vector => "vector",
429 Kind::Custom(name) => name,
430 }
431}
432
433fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
434 if matches!(node.kind, Kind::Inlines) {
435 for c in &mut node.children {
443 ui_state.layout.computed_rects.insert(
444 c.computed_id.clone(),
445 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
446 );
447 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
451 }
452 return;
453 }
454 if let Some(items) = node.virtual_items.clone() {
455 layout_virtual(node, node_rect, items, ui_state);
456 return;
457 }
458 if let Some(layout_fn) = node.layout_override.clone() {
459 layout_custom(node, node_rect, layout_fn, ui_state);
460 if node.scrollable {
461 apply_scroll_offset(node, node_rect, ui_state);
462 }
463 return;
464 }
465 match node.axis {
466 Axis::Overlay => {
467 let inner = node_rect.inset(node.padding);
468 for c in &mut node.children {
469 let c_rect = overlay_rect(c, inner, node.align, node.justify);
470 ui_state
471 .layout
472 .computed_rects
473 .insert(c.computed_id.clone(), c_rect);
474 layout_children(c, c_rect, ui_state);
475 }
476 }
477 Axis::Column => layout_axis(node, node_rect, true, ui_state),
478 Axis::Row => layout_axis(node, node_rect, false, ui_state),
479 }
480 if node.scrollable {
481 apply_scroll_offset(node, node_rect, ui_state);
482 }
483}
484
485fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
486 let inner = node_rect.inset(node.padding);
487 let measure = |c: &El| intrinsic(c);
488 let key_index = &ui_state.layout.key_index;
493 let computed_rects = &ui_state.layout.computed_rects;
494 let rect_of_key = |key: &str| -> Option<Rect> {
495 let id = key_index.get(key)?;
496 computed_rects.get(id).copied()
497 };
498 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
499 let rects = (layout_fn.0)(LayoutCtx {
500 container: inner,
501 children: &node.children,
502 measure: &measure,
503 rect_of_key: &rect_of_key,
504 rect_of_id: &rect_of_id,
505 });
506 assert_eq!(
507 rects.len(),
508 node.children.len(),
509 "LayoutFn for {:?} returned {} rects for {} children",
510 node.computed_id,
511 rects.len(),
512 node.children.len(),
513 );
514 for (c, c_rect) in node.children.iter_mut().zip(rects) {
515 ui_state
516 .layout
517 .computed_rects
518 .insert(c.computed_id.clone(), c_rect);
519 layout_children(c, c_rect, ui_state);
520 }
521}
522
523fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
529 let inner = node_rect.inset(node.padding);
530 match items.mode {
531 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
532 node,
533 inner,
534 items.count,
535 row_height,
536 items.build_row,
537 ui_state,
538 ),
539 VirtualMode::Dynamic {
540 estimated_row_height,
541 } => layout_virtual_dynamic(
542 node,
543 inner,
544 items.count,
545 estimated_row_height,
546 DynamicVirtualFns {
547 anchor_policy: items.anchor_policy,
548 row_key: items.row_key,
549 build_row: items.build_row,
550 },
551 ui_state,
552 ),
553 }
554}
555
556fn resolve_scroll_requests<F, K>(
566 node: &El,
567 inner: Rect,
568 count: usize,
569 row_extent: F,
570 row_for_key: K,
571 ui_state: &mut UiState,
572) -> bool
573where
574 F: Fn(usize) -> (f32, f32),
575 K: Fn(&str) -> Option<usize>,
576{
577 if ui_state.scroll.pending_requests.is_empty() {
578 return false;
579 }
580 let Some(key) = node.key.as_deref() else {
581 return false;
582 };
583 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
584 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
585 pending.into_iter().partition(|req| match req {
586 ScrollRequest::ToRow { list_key, .. } => list_key == key,
587 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
588 ScrollRequest::EnsureVisible { .. } => false,
591 });
592 ui_state.scroll.pending_requests = remaining;
593
594 let mut wrote = false;
595 for req in matched {
596 let (row, align) = match req {
597 ScrollRequest::ToRow { row, align, .. } => (row, align),
598 ScrollRequest::ToRowKey { row_key, align, .. } => {
599 let Some(row) = row_for_key(&row_key) else {
600 continue;
601 };
602 (row, align)
603 }
604 ScrollRequest::EnsureVisible { .. } => continue,
605 };
606 if row >= count {
607 continue;
608 }
609 let (row_top, row_h) = row_extent(row);
610 let row_bottom = row_top + row_h;
611 let viewport_h = inner.h;
612 let current = ui_state
613 .scroll
614 .offsets
615 .get(&node.computed_id)
616 .copied()
617 .unwrap_or(0.0);
618 let new_offset = match align {
619 ScrollAlignment::Start => row_top,
620 ScrollAlignment::End => row_bottom - viewport_h,
621 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
622 ScrollAlignment::Visible => {
623 if row_top < current {
624 row_top
625 } else if row_bottom > current + viewport_h {
626 row_bottom - viewport_h
627 } else {
628 continue;
629 }
630 }
631 };
632 ui_state
633 .scroll
634 .offsets
635 .insert(node.computed_id.clone(), new_offset);
636 wrote = true;
637 }
638 wrote
639}
640
641fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
644 let max_offset = (total_h - inner.h).max(0.0);
645 let stored = ui_state
646 .scroll
647 .offsets
648 .get(&node.computed_id)
649 .copied()
650 .unwrap_or(0.0);
651 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
652 let offset = stored.clamp(0.0, max_offset);
653 ui_state
654 .scroll
655 .offsets
656 .insert(node.computed_id.clone(), offset);
657 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
658 offset
659}
660
661fn write_virtual_scroll_metrics(
662 node: &El,
663 inner: Rect,
664 total_h: f32,
665 max_offset: f32,
666 offset: f32,
667 ui_state: &mut UiState,
668) {
669 ui_state.scroll.metrics.insert(
670 node.computed_id.clone(),
671 crate::state::ScrollMetrics {
672 viewport_h: inner.h,
673 content_h: total_h,
674 max_offset,
675 },
676 );
677 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
678}
679
680fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
684 let role = role_token(&child.kind);
685 let suffix = match (&child.key, role) {
686 (Some(k), r) => format!("{r}[{k}]"),
687 (None, r) => format!("{r}.{global_i}"),
688 };
689 assign_id(child, &format!("{parent_id}.{suffix}"));
690}
691
692fn layout_virtual_fixed(
693 node: &mut El,
694 inner: Rect,
695 count: usize,
696 row_height: f32,
697 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
698 ui_state: &mut UiState,
699) {
700 let gap = node.gap.max(0.0);
701 let pitch = row_height + gap;
702 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
703 resolve_scroll_requests(
704 node,
705 inner,
706 count,
707 |i| (i as f32 * pitch, row_height),
708 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
709 ui_state,
710 );
711 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
712
713 if count == 0 {
714 node.children.clear();
715 return;
716 }
717
718 let start = (offset / pitch).floor() as usize;
722 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
723
724 let mut realized: Vec<El> = Vec::new();
725 for global_i in start..end {
726 let row_top = global_i as f32 * pitch;
727 if row_top >= offset + inner.h || row_top + row_height <= offset {
728 continue;
729 }
730 let mut child = (build_row)(global_i);
731 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
732
733 let row_y = inner.y + row_top - offset;
734 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
735 ui_state
736 .layout
737 .computed_rects
738 .insert(child.computed_id.clone(), c_rect);
739 layout_children(&mut child, c_rect, ui_state);
740 realized.push(child);
741 }
742 node.children = realized;
743}
744
745fn layout_virtual_dynamic(
746 node: &mut El,
747 inner: Rect,
748 count: usize,
749 estimated_row_height: f32,
750 fns: DynamicVirtualFns,
751 ui_state: &mut UiState,
752) {
753 let gap = node.gap.max(0.0);
754 let width_bucket = virtual_width_bucket(inner.w);
755 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
756 prune_dynamic_measurements(node, &row_keys, ui_state);
757
758 if count == 0 {
759 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
760 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
761 debug_assert_eq!(offset, 0.0);
762 node.children.clear();
763 return;
764 }
765
766 let mut row_heights = dynamic_row_heights(
767 node,
768 &row_keys,
769 width_bucket,
770 estimated_row_height,
771 ui_state,
772 );
773
774 let has_request = node.key.as_deref().is_some_and(|k| {
780 ui_state.scroll.pending_requests.iter().any(|r| match r {
781 ScrollRequest::ToRow { list_key, .. } => list_key == k,
782 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
783 ScrollRequest::EnsureVisible { .. } => false,
784 })
785 });
786 let mut request_wrote = false;
787 if has_request {
788 request_wrote = resolve_scroll_requests(
789 node,
790 inner,
791 count,
792 |target| {
793 (
794 dynamic_row_top(&row_heights, gap, target),
795 row_heights[target],
796 )
797 },
798 |row_key| row_keys.iter().position(|key| key == row_key),
799 ui_state,
800 );
801 }
802
803 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
804 let max_offset = (total_h - inner.h).max(0.0);
805 let stored = ui_state
806 .scroll
807 .offsets
808 .get(&node.computed_id)
809 .copied()
810 .unwrap_or(0.0);
811 let pin_active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
812 let provisional_offset = if pin_active {
813 max_offset
814 } else if request_wrote {
815 stored
816 } else {
817 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
818 .unwrap_or(stored)
819 }
820 .clamp(0.0, max_offset);
821
822 let (measure_start, _, measure_end) =
823 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
824 measure_dynamic_range(
825 node,
826 DynamicRangeCtx {
827 inner,
828 row_keys: &row_keys,
829 width_bucket,
830 build_row: &fns.build_row,
831 },
832 measure_start,
833 measure_end,
834 ui_state,
835 );
836
837 row_heights = dynamic_row_heights(
838 node,
839 &row_keys,
840 width_bucket,
841 estimated_row_height,
842 ui_state,
843 );
844 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
845 let max_offset = (total_h - inner.h).max(0.0);
846 let stored = ui_state
847 .scroll
848 .offsets
849 .get(&node.computed_id)
850 .copied()
851 .unwrap_or(0.0);
852 let pin_resolved = resolve_pin_end(node, stored, max_offset, ui_state);
853 let pin_active = node.pin_end
854 && ui_state
855 .scroll
856 .pin_active
857 .get(&node.computed_id)
858 .copied()
859 .unwrap_or(false);
860 let mut offset = if pin_active {
861 pin_resolved
862 } else if request_wrote {
863 stored
864 } else {
865 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
866 .unwrap_or(stored)
867 }
868 .clamp(0.0, max_offset);
869
870 ui_state
871 .scroll
872 .offsets
873 .insert(node.computed_id.clone(), offset);
874
875 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
876 let mut realized_rows = layout_dynamic_range(
877 node,
878 DynamicRangeCtx {
879 inner,
880 row_keys: &row_keys,
881 width_bucket,
882 build_row: &fns.build_row,
883 },
884 offset,
885 start,
886 start_y,
887 end,
888 ui_state,
889 );
890
891 row_heights = dynamic_row_heights(
892 node,
893 &row_keys,
894 width_bucket,
895 estimated_row_height,
896 ui_state,
897 );
898 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
899 let max_offset = (total_h - inner.h).max(0.0);
900 let corrected_offset = if pin_active {
901 max_offset
902 } else if request_wrote {
903 offset
904 } else {
905 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
906 .unwrap_or(offset)
907 }
908 .clamp(0.0, max_offset);
909 if (corrected_offset - offset).abs() > 0.01 {
910 let dy = offset - corrected_offset;
911 for child in &node.children {
912 shift_subtree_y(child, dy, ui_state);
913 }
914 for row in &mut realized_rows {
915 row.rect.y += dy;
916 }
917 offset = corrected_offset;
918 ui_state
919 .scroll
920 .offsets
921 .insert(node.computed_id.clone(), offset);
922 }
923 if node.pin_end {
924 ui_state
925 .scroll
926 .pin_prev_max
927 .insert(node.computed_id.clone(), max_offset);
928 }
929 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
930
931 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
932 ui_state
933 .scroll
934 .virtual_anchors
935 .insert(node.computed_id.clone(), anchor);
936 } else {
937 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
938 }
939}
940
941struct DynamicVirtualFns {
942 anchor_policy: VirtualAnchorPolicy,
943 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
944 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
945}
946
947#[derive(Clone, Copy)]
948struct DynamicRangeCtx<'a> {
949 inner: Rect,
950 row_keys: &'a [String],
951 width_bucket: u32,
952 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
953}
954
955fn virtual_width_bucket(width: f32) -> u32 {
956 width.max(0.0).round().min(u32::MAX as f32) as u32
957}
958
959fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
960 let Some(measurements) = ui_state
961 .scroll
962 .measured_row_heights
963 .get_mut(&node.computed_id)
964 else {
965 return;
966 };
967 let live_keys = row_keys
968 .iter()
969 .map(String::as_str)
970 .collect::<FxHashSet<_>>();
971 measurements.retain(|key, widths| {
972 let live = live_keys.contains(key.as_str());
973 if live {
974 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
975 }
976 live && !widths.is_empty()
977 });
978 if measurements.is_empty() {
979 ui_state
980 .scroll
981 .measured_row_heights
982 .remove(&node.computed_id);
983 }
984}
985
986fn dynamic_row_heights(
987 node: &El,
988 row_keys: &[String],
989 width_bucket: u32,
990 estimated_row_height: f32,
991 ui_state: &UiState,
992) -> Vec<f32> {
993 let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
994 row_keys
995 .iter()
996 .map(|key| {
997 measurements
998 .and_then(|m| m.get(key))
999 .and_then(|by_width| by_width.get(&width_bucket))
1000 .copied()
1001 .unwrap_or(estimated_row_height)
1002 })
1003 .collect()
1004}
1005
1006fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1007 row_heights
1008 .iter()
1009 .take(target)
1010 .fold(0.0, |y, h| y + *h + gap)
1011}
1012
1013fn dynamic_visible_range(
1014 row_heights: &[f32],
1015 gap: f32,
1016 offset: f32,
1017 viewport_h: f32,
1018) -> (usize, f32, usize) {
1019 let count = row_heights.len();
1020 let mut start = 0;
1021 let mut y = 0.0_f32;
1022 while start < count {
1023 let h = row_heights[start];
1024 if y + h > offset {
1025 break;
1026 }
1027 y += h + gap;
1028 start += 1;
1029 }
1030
1031 let mut end = start;
1032 let mut cursor = y;
1033 let viewport_bottom = offset + viewport_h;
1034 while end < count && cursor < viewport_bottom {
1035 let h = row_heights[end];
1036 end += 1;
1037 cursor += h + gap;
1038 }
1039 (start, y, end)
1040}
1041
1042fn dynamic_anchor_offset(
1043 node: &El,
1044 row_keys: &[String],
1045 row_heights: &[f32],
1046 gap: f32,
1047 stored: f32,
1048 ui_state: &UiState,
1049) -> Option<f32> {
1050 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1051 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1052 anchor.row_index
1053 } else {
1054 row_keys.iter().position(|key| key == &anchor.row_key)?
1055 };
1056 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1057 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1058 let scroll_delta = stored - anchor.resolved_offset;
1059 let viewport_y = anchor.viewport_y - scroll_delta;
1060 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1061}
1062
1063fn measure_dynamic_range(
1064 node: &El,
1065 ctx: DynamicRangeCtx<'_>,
1066 start: usize,
1067 end: usize,
1068 ui_state: &mut UiState,
1069) {
1070 if start >= end {
1071 return;
1072 }
1073 let mut new_measurements = Vec::new();
1074 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1075 let child = (ctx.build_row)(idx);
1076 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1077 new_measurements.push((key.clone(), actual_h));
1078 }
1079 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1080}
1081
1082fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1083 match child.height {
1084 Size::Fixed(v) => v.max(0.0),
1085 Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1086 Size::Fill(_) => panic!(
1087 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
1088 Size::Fill would absorb the viewport's height and break virtualization",
1089 node.computed_id,
1090 ),
1091 }
1092}
1093
1094fn store_dynamic_measurements(
1095 node: &El,
1096 width_bucket: u32,
1097 measurements: Vec<(String, f32)>,
1098 ui_state: &mut UiState,
1099) {
1100 if measurements.is_empty() {
1101 return;
1102 }
1103 let entry = ui_state
1104 .scroll
1105 .measured_row_heights
1106 .entry(node.computed_id.clone())
1107 .or_default();
1108 for (row_key, h) in measurements {
1109 entry.entry(row_key).or_default().insert(width_bucket, h);
1110 }
1111}
1112
1113#[derive(Clone, Debug)]
1114struct DynamicRealizedRow {
1115 index: usize,
1116 key: String,
1117 rect: Rect,
1118}
1119
1120fn layout_dynamic_range(
1121 node: &mut El,
1122 ctx: DynamicRangeCtx<'_>,
1123 offset: f32,
1124 start: usize,
1125 start_y: f32,
1126 end: usize,
1127 ui_state: &mut UiState,
1128) -> Vec<DynamicRealizedRow> {
1129 let gap = node.gap.max(0.0);
1130 let mut cursor_y = start_y;
1131 let mut realized = Vec::new();
1132 let mut realized_rows = Vec::new();
1133 let mut new_measurements = Vec::new();
1134
1135 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1136 let mut child = (ctx.build_row)(idx);
1137 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1138 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1139 new_measurements.push((key.clone(), actual_h));
1140
1141 let row_y = ctx.inner.y + cursor_y - offset;
1142 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1143 ui_state
1144 .layout
1145 .computed_rects
1146 .insert(child.computed_id.clone(), c_rect);
1147 layout_children(&mut child, c_rect, ui_state);
1148
1149 realized_rows.push(DynamicRealizedRow {
1150 index: idx,
1151 key: key.clone(),
1152 rect: c_rect,
1153 });
1154 realized.push(child);
1155 cursor_y += actual_h + gap;
1156 }
1157
1158 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1159 node.children = realized;
1160 realized_rows
1161}
1162
1163fn choose_dynamic_anchor(
1164 policy: VirtualAnchorPolicy,
1165 inner: Rect,
1166 offset: f32,
1167 rows: &[DynamicRealizedRow],
1168) -> Option<VirtualAnchor> {
1169 let visible = rows
1170 .iter()
1171 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1172 .collect::<Vec<_>>();
1173 if visible.is_empty() {
1174 return None;
1175 }
1176
1177 let chosen = match policy {
1178 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1179 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1180 visible
1181 .iter()
1182 .min_by(|a, b| {
1183 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1184 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1185 ad.total_cmp(&bd)
1186 })
1187 .copied()
1188 .map(|row| {
1189 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1190 (row.clone(), anchor_y)
1191 })
1192 }
1193 VirtualAnchorPolicy::FirstVisible => {
1194 let row = visible
1195 .iter()
1196 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1197 .or_else(|| visible.first())
1198 .copied()?;
1199 let anchor_y = row.rect.y.max(inner.y);
1200 Some((row.clone(), anchor_y))
1201 }
1202 VirtualAnchorPolicy::LastVisible => {
1203 let row = visible
1204 .iter()
1205 .rev()
1206 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1207 .or_else(|| visible.last())
1208 .copied()?;
1209 let anchor_y = row.rect.bottom().min(inner.bottom());
1210 Some((row.clone(), anchor_y))
1211 }
1212 }?;
1213
1214 let (row, anchor_y) = chosen;
1215 let row_h = row.rect.h.max(0.0);
1216 let row_fraction = if row_h > 0.0 {
1217 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1218 } else {
1219 0.0
1220 };
1221 Some(VirtualAnchor {
1222 row_key: row.key.clone(),
1223 row_index: row.index,
1224 row_fraction,
1225 viewport_y: anchor_y - inner.y,
1226 resolved_offset: offset,
1227 })
1228}
1229
1230fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1231 if y < top {
1232 top - y
1233 } else if y > bottom {
1234 y - bottom
1235 } else {
1236 0.0
1237 }
1238}
1239
1240fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1241 if count == 0 {
1242 0.0
1243 } else {
1244 row_sum + gap * count.saturating_sub(1) as f32
1245 }
1246}
1247
1248fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1256 let inner = node_rect.inset(node.padding);
1257 if node.children.is_empty() {
1258 ui_state
1259 .scroll
1260 .offsets
1261 .insert(node.computed_id.clone(), 0.0);
1262 ui_state.scroll.metrics.insert(
1263 node.computed_id.clone(),
1264 crate::state::ScrollMetrics {
1265 viewport_h: inner.h,
1266 content_h: 0.0,
1267 max_offset: 0.0,
1268 },
1269 );
1270 return;
1271 }
1272 let content_bottom = node
1273 .children
1274 .iter()
1275 .map(|c| ui_state.rect(&c.computed_id).bottom())
1276 .fold(f32::NEG_INFINITY, f32::max);
1277 let content_h = (content_bottom - inner.y).max(0.0);
1278 let max_offset = (content_h - inner.h).max(0.0);
1279
1280 resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1288
1289 let stored = ui_state
1290 .scroll
1291 .offsets
1292 .get(&node.computed_id)
1293 .copied()
1294 .unwrap_or(0.0);
1295 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
1296 let clamped = stored.clamp(0.0, max_offset);
1297 if clamped > 0.0 {
1298 for c in &node.children {
1299 shift_subtree_y(c, -clamped, ui_state);
1300 }
1301 }
1302 ui_state
1303 .scroll
1304 .offsets
1305 .insert(node.computed_id.clone(), clamped);
1306 ui_state.scroll.metrics.insert(
1307 node.computed_id.clone(),
1308 crate::state::ScrollMetrics {
1309 viewport_h: inner.h,
1310 content_h,
1311 max_offset,
1312 },
1313 );
1314
1315 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1316}
1317
1318const PIN_END_EPSILON: f32 = 0.5;
1323
1324fn pin_end_would_be_active(
1325 node: &El,
1326 stored: f32,
1327 _max_offset: f32,
1328 ui_state: &UiState,
1329) -> Option<bool> {
1330 if !node.pin_end {
1331 return None;
1332 }
1333 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1334 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1335 Some(match prev_active {
1336 None => true,
1337 Some(prev) => {
1338 let prev_max = prev_max.unwrap_or(0.0);
1339 if prev && stored < prev_max - PIN_END_EPSILON {
1340 false
1341 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
1342 true
1343 } else {
1344 prev
1345 }
1346 }
1347 })
1348}
1349
1350fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1361 if !node.pin_end {
1362 ui_state.scroll.pin_active.remove(&node.computed_id);
1363 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1364 return stored;
1365 }
1366 let active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1367 ui_state
1368 .scroll
1369 .pin_active
1370 .insert(node.computed_id.clone(), active);
1371 ui_state
1372 .scroll
1373 .pin_prev_max
1374 .insert(node.computed_id.clone(), max_offset);
1375 if active { max_offset } else { stored }
1376}
1377
1378fn resolve_ensure_visible_for_scroll(
1391 node: &El,
1392 inner: Rect,
1393 content_h: f32,
1394 ui_state: &mut UiState,
1395) {
1396 if ui_state.scroll.pending_requests.is_empty() {
1397 return;
1398 }
1399 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1400 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1401 for req in pending {
1402 let ScrollRequest::EnsureVisible {
1403 container_key,
1404 y,
1405 h,
1406 } = &req
1407 else {
1408 remaining.push(req);
1409 continue;
1410 };
1411 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1412 remaining.push(req);
1417 continue;
1418 };
1419 let inside = node.computed_id == *ancestor_id
1422 || node
1423 .computed_id
1424 .strip_prefix(ancestor_id.as_str())
1425 .is_some_and(|rest| rest.starts_with('.'));
1426 if !inside {
1427 remaining.push(req);
1428 continue;
1429 }
1430 let current = ui_state
1431 .scroll
1432 .offsets
1433 .get(&node.computed_id)
1434 .copied()
1435 .unwrap_or(0.0);
1436 let target_top = *y;
1437 let target_bottom = *y + *h;
1438 let viewport_h = inner.h;
1439 let new_offset = if target_top < current {
1446 target_top
1447 } else if target_bottom > current + viewport_h {
1448 target_bottom - viewport_h
1449 } else {
1450 continue;
1455 };
1456 let max = (content_h - viewport_h).max(0.0);
1460 let new_offset = new_offset.clamp(0.0, max);
1461 ui_state
1462 .scroll
1463 .offsets
1464 .insert(node.computed_id.clone(), new_offset);
1465 }
1466 ui_state.scroll.pending_requests = remaining;
1467}
1468
1469fn write_thumb_rect(
1477 node: &El,
1478 inner: Rect,
1479 content_h: f32,
1480 max_offset: f32,
1481 offset: f32,
1482 ui_state: &mut UiState,
1483) {
1484 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1485 return;
1486 }
1487 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1488 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1489 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1490 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1491 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1492 let track_remaining = (inner.h - thumb_h).max(0.0);
1493 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1494 let thumb_x = inner.right() - thumb_w - track_inset;
1495 let track_x = inner.right() - track_w - track_inset;
1496 ui_state.scroll.thumb_rects.insert(
1497 node.computed_id.clone(),
1498 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1499 );
1500 ui_state.scroll.thumb_tracks.insert(
1501 node.computed_id.clone(),
1502 Rect::new(track_x, inner.y, track_w, inner.h),
1503 );
1504}
1505
1506fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1507 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1508 rect.y += dy;
1509 }
1510 for c in &node.children {
1511 shift_subtree_y(c, dy, ui_state);
1512 }
1513}
1514
1515fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1516 let inner = node_rect.inset(node.padding);
1517 let n = node.children.len();
1518 if n == 0 {
1519 return;
1520 }
1521
1522 let total_gap = node.gap * n.saturating_sub(1) as f32;
1523 let main_extent = if vertical { inner.h } else { inner.w };
1524 let cross_extent = if vertical { inner.w } else { inner.h };
1525
1526 let intrinsics: Vec<(f32, f32)> = {
1527 crate::profile_span!("layout::axis::intrinsics");
1528 node.children
1529 .iter()
1530 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1531 .collect()
1532 };
1533
1534 let mut consumed = 0.0;
1535 let mut fill_weight_total = 0.0;
1536 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1537 match main_size_of(c, *iw, *ih, vertical) {
1538 MainSize::Resolved(v) => consumed += v,
1539 MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1540 }
1541 }
1542 let remaining = (main_extent - consumed - total_gap).max(0.0);
1543
1544 let free_after_used = if fill_weight_total == 0.0 {
1548 remaining
1549 } else {
1550 0.0
1551 };
1552 let mut cursor = match node.justify {
1553 Justify::Start => 0.0,
1554 Justify::Center => free_after_used * 0.5,
1555 Justify::End => free_after_used,
1556 Justify::SpaceBetween => 0.0,
1557 };
1558 let between_extra =
1559 if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1560 remaining / (n - 1) as f32
1561 } else {
1562 0.0
1563 };
1564 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1565
1566 crate::profile_span!("layout::axis::place");
1567 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1568 let main_size = match main_size_of(c, iw, ih, vertical) {
1569 MainSize::Resolved(v) => v,
1570 MainSize::Fill(w) => remaining * w.max(0.001) / fill_weight_total.max(0.001),
1571 };
1572
1573 let cross_intent = if vertical { c.width } else { c.height };
1574 let cross_intrinsic = if vertical { iw } else { ih };
1575 let cross_size = match cross_intent {
1584 Size::Fixed(v) => v,
1585 Size::Hug | Size::Fill(_) => match node.align {
1586 Align::Stretch => cross_extent,
1587 Align::Start | Align::Center | Align::End => cross_intrinsic,
1588 },
1589 };
1590
1591 let cross_off = match node.align {
1592 Align::Start | Align::Stretch => 0.0,
1593 Align::Center => (cross_extent - cross_size) * 0.5,
1594 Align::End => cross_extent - cross_size,
1595 };
1596
1597 let c_rect = if vertical {
1598 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1599 } else {
1600 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1601 };
1602 ui_state
1603 .layout
1604 .computed_rects
1605 .insert(c.computed_id.clone(), c_rect);
1606 if can_prune_scroll_child(c, c_rect, scroll_visible) {
1607 let nodes = zero_descendant_rects(c, c_rect, ui_state);
1608 record_pruned_subtree(nodes);
1609 } else {
1610 layout_children(c, c_rect, ui_state);
1611 }
1612
1613 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1614 }
1615}
1616
1617const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1618
1619fn scroll_visible_content_rect(
1620 node: &El,
1621 inner: Rect,
1622 vertical: bool,
1623 ui_state: &UiState,
1624) -> Option<Rect> {
1625 if !vertical || !node.scrollable || node.pin_end {
1626 return None;
1627 }
1628 let offset = ui_state
1629 .scroll
1630 .offsets
1631 .get(&node.computed_id)
1632 .copied()
1633 .unwrap_or(0.0)
1634 .max(0.0);
1635 Some(Rect::new(
1636 inner.x,
1637 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1638 inner.w,
1639 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1640 ))
1641}
1642
1643fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1644 let Some(visible) = visible else {
1645 return false;
1646 };
1647 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1648}
1649
1650fn subtree_is_layout_confined(node: &El) -> bool {
1651 if node.translate != (0.0, 0.0)
1652 || node.scale != 1.0
1653 || node.shadow > 0.0
1654 || node.paint_overflow != Sides::zero()
1655 || node.hit_overflow != Sides::zero()
1656 || node.layout_override.is_some()
1657 || node.virtual_items.is_some()
1658 {
1659 return false;
1660 }
1661 node.children.iter().all(subtree_is_layout_confined)
1662}
1663
1664fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1665 let mut count = 0;
1666 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1667 for child in &node.children {
1668 ui_state
1669 .layout
1670 .computed_rects
1671 .insert(child.computed_id.clone(), zero);
1672 count += 1 + zero_descendant_rects(child, zero, ui_state);
1673 }
1674 count
1675}
1676
1677fn record_pruned_subtree(nodes: u64) {
1678 INTRINSIC_CACHE.with(|cell| {
1679 if let Some(cache) = cell.borrow_mut().as_mut() {
1680 cache.prune.subtrees += 1;
1681 cache.prune.nodes += nodes;
1682 }
1683 });
1684}
1685
1686enum MainSize {
1687 Resolved(f32),
1688 Fill(f32),
1689}
1690
1691fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1692 let s = if vertical { c.height } else { c.width };
1693 let intr = if vertical { ih } else { iw };
1694 match s {
1695 Size::Fixed(v) => MainSize::Resolved(v),
1696 Size::Hug => MainSize::Resolved(intr),
1697 Size::Fill(w) => MainSize::Fill(w),
1698 }
1699}
1700
1701fn child_intrinsic(
1702 c: &El,
1703 vertical: bool,
1704 parent_cross_extent: f32,
1705 parent_align: Align,
1706) -> (f32, f32) {
1707 if !vertical {
1708 return intrinsic(c);
1709 }
1710 let available_width = match c.width {
1711 Size::Fixed(v) => Some(v),
1712 Size::Fill(_) => Some(parent_cross_extent),
1713 Size::Hug => match parent_align {
1714 Align::Stretch => Some(parent_cross_extent),
1715 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1716 },
1717 };
1718 intrinsic_constrained(c, available_width)
1719}
1720
1721fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1722 let constrained_width = match c.width {
1729 Size::Fixed(v) => Some(v),
1730 Size::Fill(_) | Size::Hug => Some(parent.w),
1731 };
1732 let (iw, ih) = intrinsic_constrained(c, constrained_width);
1733 let w = match c.width {
1734 Size::Fixed(v) => v,
1735 Size::Hug => iw.min(parent.w),
1736 Size::Fill(_) => parent.w,
1737 };
1738 let h = match c.height {
1739 Size::Fixed(v) => v,
1740 Size::Hug => ih.min(parent.h),
1741 Size::Fill(_) => parent.h,
1742 };
1743 let x = match align {
1744 Align::Start | Align::Stretch => parent.x,
1745 Align::Center => parent.x + (parent.w - w) * 0.5,
1746 Align::End => parent.right() - w,
1747 };
1748 let y = match justify {
1749 Justify::Start | Justify::SpaceBetween => parent.y,
1750 Justify::Center => parent.y + (parent.h - h) * 0.5,
1751 Justify::End => parent.bottom() - h,
1752 };
1753 Rect::new(x, y, w, h)
1754}
1755
1756pub fn intrinsic(c: &El) -> (f32, f32) {
1758 intrinsic_constrained(c, None)
1759}
1760
1761fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1762 let key = intrinsic_cache_key(c, available_width);
1763 if let Some(key) = &key
1764 && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
1765 let mut slot = cell.borrow_mut();
1766 let cache = slot.as_mut()?;
1767 let cached = cache.measurements.get(key).copied();
1768 if cached.is_some() {
1769 cache.stats.hits += 1;
1770 }
1771 cached
1772 })
1773 {
1774 return cached;
1775 }
1776
1777 if key.is_some() {
1778 INTRINSIC_CACHE.with(|cell| {
1779 if let Some(cache) = cell.borrow_mut().as_mut() {
1780 cache.stats.misses += 1;
1781 }
1782 });
1783 }
1784
1785 let measured = intrinsic_constrained_uncached(c, available_width);
1786
1787 if let Some(key) = key {
1788 INTRINSIC_CACHE.with(|cell| {
1789 if let Some(cache) = cell.borrow_mut().as_mut() {
1790 cache.measurements.insert(key, measured);
1791 }
1792 });
1793 }
1794
1795 measured
1796}
1797
1798fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
1799 if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
1800 return None;
1801 }
1802 if c.computed_id.is_empty() {
1803 return None;
1804 }
1805 Some(IntrinsicCacheKey {
1806 computed_id: c.computed_id.clone(),
1807 available_width_bits: available_width.map(f32::to_bits),
1808 })
1809}
1810
1811fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
1812 if c.layout_override.is_some() {
1813 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1818 panic!(
1819 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1820 Size::Hug is not supported for custom layouts",
1821 c.computed_id,
1822 );
1823 }
1824 return apply_min(c, 0.0, 0.0);
1825 }
1826 if c.virtual_items.is_some() {
1827 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1832 panic!(
1833 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1834 Size::Hug would defeat virtualization",
1835 c.computed_id,
1836 );
1837 }
1838 return apply_min(c, 0.0, 0.0);
1839 }
1840 if matches!(c.kind, Kind::Inlines) {
1841 return inline_paragraph_intrinsic(c, available_width);
1842 }
1843 if matches!(c.kind, Kind::HardBreak) {
1844 return apply_min(c, 0.0, 0.0);
1848 }
1849 if matches!(c.kind, Kind::Math) {
1850 if let Some(expr) = &c.math {
1851 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
1852 return apply_min(
1853 c,
1854 layout.width + c.padding.left + c.padding.right,
1855 layout.height() + c.padding.top + c.padding.bottom,
1856 );
1857 }
1858 return apply_min(c, 0.0, 0.0);
1859 }
1860 if c.icon.is_some() {
1861 return apply_min(
1862 c,
1863 c.font_size + c.padding.left + c.padding.right,
1864 c.font_size + c.padding.top + c.padding.bottom,
1865 );
1866 }
1867 if let Some(img) = &c.image {
1868 let w = img.width() as f32 + c.padding.left + c.padding.right;
1872 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
1873 return apply_min(c, w, h);
1874 }
1875 if let Some(text) = &c.text {
1876 let content_available = match c.text_wrap {
1877 TextWrap::NoWrap => None,
1878 TextWrap::Wrap => available_width
1879 .or(match c.width {
1880 Size::Fixed(v) => Some(v),
1881 Size::Fill(_) | Size::Hug => None,
1882 })
1883 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1884 };
1885 let display = display_text_for_measure(c, text, content_available);
1886 let layout = text_metrics::layout_text_with_line_height_and_family(
1887 &display,
1888 c.font_size,
1889 c.line_height,
1890 c.font_family,
1891 c.font_weight,
1892 c.font_mono,
1893 c.text_wrap,
1894 content_available,
1895 );
1896 let w = match (content_available, c.width) {
1897 (Some(available), Size::Hug) => {
1898 let unwrapped = text_metrics::layout_text_with_family(
1899 text,
1900 c.font_size,
1901 c.font_family,
1902 c.font_weight,
1903 c.font_mono,
1904 TextWrap::NoWrap,
1905 None,
1906 );
1907 unwrapped.width.min(available) + c.padding.left + c.padding.right
1908 }
1909 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
1910 available + c.padding.left + c.padding.right
1911 }
1912 (None, _) => layout.width + c.padding.left + c.padding.right,
1913 };
1914 let h = layout.height + c.padding.top + c.padding.bottom;
1915 return apply_min(c, w, h);
1916 }
1917 match c.axis {
1918 Axis::Overlay => {
1919 let mut w: f32 = 0.0;
1920 let mut h: f32 = 0.0;
1921 for ch in &c.children {
1922 let child_available =
1923 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1924 let (cw, chh) = intrinsic_constrained(ch, child_available);
1925 w = w.max(cw);
1926 h = h.max(chh);
1927 }
1928 apply_min(
1929 c,
1930 w + c.padding.left + c.padding.right,
1931 h + c.padding.top + c.padding.bottom,
1932 )
1933 }
1934 Axis::Column => {
1935 let mut w: f32 = 0.0;
1936 let mut h: f32 = c.padding.top + c.padding.bottom;
1937 let n = c.children.len();
1938 let child_available =
1939 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1940 for (i, ch) in c.children.iter().enumerate() {
1941 let (cw, chh) = intrinsic_constrained(ch, child_available);
1942 w = w.max(cw);
1943 h += chh;
1944 if i + 1 < n {
1945 h += c.gap;
1946 }
1947 }
1948 apply_min(c, w + c.padding.left + c.padding.right, h)
1949 }
1950 Axis::Row => {
1951 let n = c.children.len();
1961 let total_gap = c.gap * n.saturating_sub(1) as f32;
1962 let inner_available = available_width
1963 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
1964
1965 let mut consumed: f32 = 0.0;
1971 let mut fill_weight_total: f32 = 0.0;
1972 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1973 for ch in &c.children {
1974 match ch.width {
1975 Size::Fill(w) => {
1976 fill_weight_total += w.max(0.001);
1977 sizes.push(None);
1978 }
1979 _ => {
1980 let (cw, chh) = intrinsic(ch);
1981 consumed += cw;
1982 sizes.push(Some((cw, chh)));
1983 }
1984 }
1985 }
1986
1987 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
1995 let mut w_total: f32 = c.padding.left + c.padding.right;
1996 let mut h_max: f32 = 0.0;
1997 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
1998 let (cw, chh) = match slot {
1999 Some(rc) => rc,
2000 None => match (fill_remaining, fill_weight_total > 0.0) {
2001 (Some(av), true) => {
2002 let weight = match ch.width {
2003 Size::Fill(w) => w.max(0.001),
2004 _ => 1.0,
2005 };
2006 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2007 }
2008 _ => intrinsic(ch),
2009 },
2010 };
2011 w_total += cw;
2012 if i + 1 < n {
2013 w_total += c.gap;
2014 }
2015 h_max = h_max.max(chh);
2016 }
2017 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2018 }
2019 }
2020}
2021
2022pub(crate) fn text_layout(
2023 c: &El,
2024 available_width: Option<f32>,
2025) -> Option<text_metrics::TextLayout> {
2026 let text = c.text.as_ref()?;
2027 let content_available = match c.text_wrap {
2028 TextWrap::NoWrap => None,
2029 TextWrap::Wrap => available_width
2030 .or(match c.width {
2031 Size::Fixed(v) => Some(v),
2032 Size::Fill(_) | Size::Hug => None,
2033 })
2034 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2035 };
2036 let display = display_text_for_measure(c, text, content_available);
2037 Some(text_metrics::layout_text_with_line_height_and_family(
2038 &display,
2039 c.font_size,
2040 c.line_height,
2041 c.font_family,
2042 c.font_weight,
2043 c.font_mono,
2044 c.text_wrap,
2045 content_available,
2046 ))
2047}
2048
2049fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2050 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2051 (c.text_wrap, c.text_max_lines, available_width)
2052 {
2053 text_metrics::clamp_text_to_lines_with_family(
2054 text,
2055 c.font_size,
2056 c.font_family,
2057 c.font_weight,
2058 c.font_mono,
2059 width,
2060 max_lines,
2061 )
2062 } else {
2063 text.to_string()
2064 }
2065}
2066
2067fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2068 if let Size::Fixed(v) = c.width {
2069 w = v;
2070 }
2071 if let Size::Fixed(v) = c.height {
2072 h = v;
2073 }
2074 (w, h)
2075}
2076
2077fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2090 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2091 return inline_mixed_intrinsic(node, available_width);
2092 }
2093 let concat = concat_inline_text(&node.children);
2094 let size = inline_paragraph_size(node);
2095 let line_height = inline_paragraph_line_height(node);
2096 let content_available = match node.text_wrap {
2097 TextWrap::NoWrap => None,
2098 TextWrap::Wrap => available_width
2099 .or(match node.width {
2100 Size::Fixed(v) => Some(v),
2101 Size::Fill(_) | Size::Hug => None,
2102 })
2103 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2104 };
2105 let layout = text_metrics::layout_text_with_line_height_and_family(
2106 &concat,
2107 size,
2108 line_height,
2109 node.font_family,
2110 FontWeight::Regular,
2111 false,
2112 node.text_wrap,
2113 content_available,
2114 );
2115 let w = match (content_available, node.width) {
2116 (Some(available), Size::Hug) => {
2117 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2118 &concat,
2119 size,
2120 line_height,
2121 node.font_family,
2122 FontWeight::Regular,
2123 false,
2124 TextWrap::NoWrap,
2125 None,
2126 );
2127 unwrapped.width.min(available) + node.padding.left + node.padding.right
2128 }
2129 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2130 available + node.padding.left + node.padding.right
2131 }
2132 (None, _) => layout.width + node.padding.left + node.padding.right,
2133 };
2134 let h = layout.height + node.padding.top + node.padding.bottom;
2135 apply_min(node, w, h)
2136}
2137
2138fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2139 let wrap_width = match node.text_wrap {
2140 TextWrap::Wrap => available_width.or(match node.width {
2141 Size::Fixed(v) => Some(v),
2142 Size::Fill(_) | Size::Hug => None,
2143 }),
2144 TextWrap::NoWrap => None,
2145 }
2146 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2147
2148 let mut breaker = crate::inline_mixed::MixedInlineBreaker::new(
2149 node.text_wrap,
2150 wrap_width,
2151 node.font_size * 0.82,
2152 node.font_size * 0.22,
2153 node.line_height,
2154 );
2155
2156 for child in &node.children {
2157 match child.kind {
2158 Kind::HardBreak => {
2159 breaker.finish_line();
2160 continue;
2161 }
2162 Kind::Text => {
2163 let text = child.text.as_deref().unwrap_or("");
2164 for chunk in inline_text_chunks(text) {
2165 let is_space = chunk.chars().all(char::is_whitespace);
2166 if breaker.skips_leading_space(is_space) {
2167 continue;
2168 }
2169 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2170 if breaker.wraps_before(is_space, w) {
2171 breaker.finish_line();
2172 }
2173 if breaker.skips_overflowing_space(is_space, w) {
2174 continue;
2175 }
2176 breaker.push(w, ascent, descent);
2177 }
2178 continue;
2179 }
2180 _ => {}
2181 }
2182 let (w, ascent, descent) = inline_child_metrics(child);
2183 if breaker.wraps_before(false, w) {
2184 breaker.finish_line();
2185 }
2186 breaker.push(w, ascent, descent);
2187 }
2188 let measurement = breaker.finish();
2189 let w = measurement.width + node.padding.left + node.padding.right;
2190 let h = measurement.height + node.padding.top + node.padding.bottom;
2191 apply_min(node, w, h)
2192}
2193
2194fn inline_text_chunks(text: &str) -> Vec<&str> {
2195 let mut chunks = Vec::new();
2196 let mut start = 0;
2197 let mut last_space = None;
2198 for (i, ch) in text.char_indices() {
2199 let is_space = ch.is_whitespace();
2200 match last_space {
2201 None => last_space = Some(is_space),
2202 Some(prev) if prev != is_space => {
2203 chunks.push(&text[start..i]);
2204 start = i;
2205 last_space = Some(is_space);
2206 }
2207 _ => {}
2208 }
2209 }
2210 if start < text.len() {
2211 chunks.push(&text[start..]);
2212 }
2213 chunks
2214}
2215
2216fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2217 let layout = text_metrics::layout_text_with_line_height_and_family(
2218 text,
2219 child.font_size,
2220 child.line_height,
2221 child.font_family,
2222 child.font_weight,
2223 child.font_mono,
2224 TextWrap::NoWrap,
2225 None,
2226 );
2227 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2228}
2229
2230fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2231 match child.kind {
2232 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2233 Kind::Math => {
2234 if let Some(expr) = &child.math {
2235 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2236 (layout.width, layout.ascent, layout.descent)
2237 } else {
2238 (0.0, 0.0, 0.0)
2239 }
2240 }
2241 _ => (0.0, 0.0, 0.0),
2242 }
2243}
2244
2245fn concat_inline_text(children: &[El]) -> String {
2252 let mut s = String::new();
2253 for c in children {
2254 match c.kind {
2255 Kind::Text => {
2256 if let Some(t) = &c.text {
2257 s.push_str(t);
2258 }
2259 }
2260 Kind::HardBreak => s.push('\n'),
2261 _ => {}
2262 }
2263 }
2264 s
2265}
2266
2267fn inline_paragraph_size(node: &El) -> f32 {
2271 let mut size: f32 = node.font_size;
2272 for c in &node.children {
2273 if matches!(c.kind, Kind::Text) {
2274 size = size.max(c.font_size);
2275 }
2276 }
2277 size
2278}
2279
2280fn inline_paragraph_line_height(node: &El) -> f32 {
2281 let mut line_height: f32 = node.line_height;
2282 let mut max_size: f32 = node.font_size;
2283 for c in &node.children {
2284 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2285 max_size = c.font_size;
2286 line_height = c.line_height;
2287 }
2288 }
2289 line_height
2290}
2291
2292#[cfg(test)]
2293mod tests {
2294 use super::*;
2295 use crate::state::UiState;
2296
2297 #[test]
2302 fn align_center_shrinks_fill_child_to_intrinsic() {
2303 let mut root = column([crate::row([crate::widgets::text::text("hi")
2307 .width(Size::Fixed(40.0))
2308 .height(Size::Fixed(20.0))])])
2309 .align(Align::Center)
2310 .width(Size::Fixed(200.0))
2311 .height(Size::Fixed(100.0));
2312 let mut state = UiState::new();
2313 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2314 let row_rect = state.rect(&root.children[0].computed_id);
2315 assert!(
2318 (row_rect.x - 80.0).abs() < 0.5,
2319 "expected x≈80 (centered), got {}",
2320 row_rect.x
2321 );
2322 assert!(
2323 (row_rect.w - 40.0).abs() < 0.5,
2324 "expected w≈40 (shrunk to intrinsic), got {}",
2325 row_rect.w
2326 );
2327 }
2328
2329 #[test]
2332 fn align_stretch_preserves_fill_stretch() {
2333 let mut root = column([crate::row([crate::widgets::text::text("hi")
2334 .width(Size::Fixed(40.0))
2335 .height(Size::Fixed(20.0))])])
2336 .align(Align::Stretch)
2337 .width(Size::Fixed(200.0))
2338 .height(Size::Fixed(100.0));
2339 let mut state = UiState::new();
2340 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2341 let row_rect = state.rect(&root.children[0].computed_id);
2342 assert!(
2343 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2344 "expected stretched (x=0, w=200), got x={} w={}",
2345 row_rect.x,
2346 row_rect.w
2347 );
2348 }
2349
2350 #[test]
2353 fn justify_center_centers_hug_children() {
2354 let mut root = column([crate::widgets::text::text("hi")
2355 .width(Size::Fixed(40.0))
2356 .height(Size::Fixed(20.0))])
2357 .justify(Justify::Center)
2358 .height(Size::Fill(1.0));
2359 let mut state = UiState::new();
2360 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2361 let child_rect = state.rect(&root.children[0].computed_id);
2362 assert!(
2364 (child_rect.y - 40.0).abs() < 0.5,
2365 "expected y≈40, got {}",
2366 child_rect.y
2367 );
2368 }
2369
2370 #[test]
2371 fn justify_end_pushes_to_bottom() {
2372 let mut root = column([crate::widgets::text::text("hi")
2373 .width(Size::Fixed(40.0))
2374 .height(Size::Fixed(20.0))])
2375 .justify(Justify::End)
2376 .height(Size::Fill(1.0));
2377 let mut state = UiState::new();
2378 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2379 let child_rect = state.rect(&root.children[0].computed_id);
2380 assert!(
2381 (child_rect.y - 80.0).abs() < 0.5,
2382 "expected y≈80, got {}",
2383 child_rect.y
2384 );
2385 }
2386
2387 #[test]
2391 fn justify_space_between_distributes_evenly() {
2392 let row_child = || {
2393 crate::widgets::text::text("x")
2394 .width(Size::Fixed(20.0))
2395 .height(Size::Fixed(20.0))
2396 };
2397 let mut root = column([row_child(), row_child(), row_child()])
2398 .justify(Justify::SpaceBetween)
2399 .height(Size::Fixed(200.0));
2400 let mut state = UiState::new();
2401 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2402 let y0 = state.rect(&root.children[0].computed_id).y;
2405 let y1 = state.rect(&root.children[1].computed_id).y;
2406 let y2 = state.rect(&root.children[2].computed_id).y;
2407 assert!(
2408 y0.abs() < 0.5,
2409 "first child should be flush at y=0, got {y0}"
2410 );
2411 assert!(
2412 (y1 - 90.0).abs() < 0.5,
2413 "middle child should be at y≈90, got {y1}"
2414 );
2415 assert!(
2416 (y2 - 180.0).abs() < 0.5,
2417 "last child should be flush at y≈180, got {y2}"
2418 );
2419 }
2420
2421 #[test]
2425 fn fill_weight_distributes_proportionally() {
2426 let big = crate::widgets::text::text("big")
2427 .width(Size::Fixed(40.0))
2428 .height(Size::Fill(2.0));
2429 let small = crate::widgets::text::text("small")
2430 .width(Size::Fixed(40.0))
2431 .height(Size::Fill(1.0));
2432 let mut root = column([big, small]).height(Size::Fixed(300.0));
2433 let mut state = UiState::new();
2434 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2435 let big_h = state.rect(&root.children[0].computed_id).h;
2437 let small_h = state.rect(&root.children[1].computed_id).h;
2438 assert!(
2439 (big_h - 200.0).abs() < 0.5,
2440 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2441 );
2442 assert!(
2443 (small_h - 100.0).abs() < 0.5,
2444 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2445 );
2446 }
2447
2448 #[test]
2452 fn padding_on_hug_includes_in_intrinsic() {
2453 let root = column([crate::widgets::text::text("x")
2454 .width(Size::Fixed(40.0))
2455 .height(Size::Fixed(40.0))])
2456 .padding(Sides::all(20.0));
2457 let (w, h) = intrinsic(&root);
2458 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2460 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2461 }
2462
2463 #[test]
2467 fn align_end_pins_to_cross_axis_far_edge() {
2468 let mut root = crate::row([crate::widgets::text::text("hi")
2469 .width(Size::Fixed(40.0))
2470 .height(Size::Fixed(20.0))])
2471 .align(Align::End)
2472 .width(Size::Fixed(200.0))
2473 .height(Size::Fixed(100.0));
2474 let mut state = UiState::new();
2475 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2476 let child_rect = state.rect(&root.children[0].computed_id);
2477 assert!(
2479 (child_rect.y - 80.0).abs() < 0.5,
2480 "expected y≈80 (pinned to bottom), got {}",
2481 child_rect.y
2482 );
2483 }
2484
2485 #[test]
2486 fn overlay_can_center_hug_child() {
2487 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2488 .width(Size::Fixed(200.0))
2489 .height(Size::Hug)])
2490 .align(Align::Center)
2491 .justify(Justify::Center);
2492 let mut state = UiState::new();
2493 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2494 let child_rect = state.rect(&root.children[0].computed_id);
2495 assert!(
2496 (child_rect.x - 200.0).abs() < 0.5,
2497 "expected x≈200, got {}",
2498 child_rect.x
2499 );
2500 assert!(
2501 child_rect.y > 100.0 && child_rect.y < 200.0,
2502 "expected centered y, got {}",
2503 child_rect.y
2504 );
2505 }
2506
2507 #[test]
2508 fn scroll_offset_translates_children_and_clamps_to_content() {
2509 let mut root = scroll(
2513 (0..6)
2514 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2515 )
2516 .key("list")
2517 .gap(12.0)
2518 .height(Size::Fixed(200.0));
2519 let mut state = UiState::new();
2520 assign_ids(&mut root);
2521 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2522
2523 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2524
2525 let stored = state
2527 .scroll
2528 .offsets
2529 .get(&root.computed_id)
2530 .copied()
2531 .unwrap_or(0.0);
2532 assert!(
2533 (stored - 80.0).abs() < 0.01,
2534 "offset clamped unexpectedly: {stored}"
2535 );
2536 let c0 = state.rect(&root.children[0].computed_id);
2538 assert!(
2539 (c0.y - (-80.0)).abs() < 0.01,
2540 "child 0 y = {} (expected -80)",
2541 c0.y
2542 );
2543 state
2545 .scroll
2546 .offsets
2547 .insert(root.computed_id.clone(), 9999.0);
2548 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2549 let stored = state
2550 .scroll
2551 .offsets
2552 .get(&root.computed_id)
2553 .copied()
2554 .unwrap_or(0.0);
2555 assert!(
2556 (stored - 160.0).abs() < 0.01,
2557 "overshoot clamped to {stored}"
2558 );
2559 let mut tiny =
2561 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
2562 .height(Size::Fixed(200.0));
2563 let mut tiny_state = UiState::new();
2564 assign_ids(&mut tiny);
2565 tiny_state
2566 .scroll
2567 .offsets
2568 .insert(tiny.computed_id.clone(), 50.0);
2569 layout(
2570 &mut tiny,
2571 &mut tiny_state,
2572 Rect::new(0.0, 0.0, 300.0, 200.0),
2573 );
2574 assert_eq!(
2575 tiny_state
2576 .scroll
2577 .offsets
2578 .get(&tiny.computed_id)
2579 .copied()
2580 .unwrap_or(0.0),
2581 0.0
2582 );
2583 }
2584
2585 #[test]
2586 fn scroll_layout_prunes_far_offscreen_descendants() {
2587 let far = column([crate::widgets::text::text("far row body").key("far-text")])
2588 .height(Size::Fixed(40.0));
2589 let mut root = scroll([
2590 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
2591 crate::tree::spacer().height(Size::Fixed(400.0)),
2592 far,
2593 ])
2594 .key("list")
2595 .height(Size::Fixed(80.0));
2596 let mut state = UiState::new();
2597 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
2598 let stats = take_prune_stats();
2599
2600 assert!(
2601 stats.subtrees >= 1,
2602 "expected at least one far scroll child to be pruned, got {stats:?}"
2603 );
2604 assert!(
2605 stats.nodes >= 1,
2606 "expected pruned descendants to be zeroed, got {stats:?}"
2607 );
2608 let far_text = state
2609 .rect_of_key(&root, "far-text")
2610 .expect("far text keeps a zero rect while pruned");
2611 assert_eq!(far_text.w, 0.0);
2612 assert_eq!(far_text.h, 0.0);
2613 }
2614
2615 #[test]
2616 fn scrollbar_thumb_size_and_position_track_overflow() {
2617 let mut root = scroll(
2620 (0..6)
2621 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2622 )
2623 .gap(12.0)
2624 .height(Size::Fixed(200.0));
2625 let mut state = UiState::new();
2626 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2627
2628 let metrics = state
2629 .scroll
2630 .metrics
2631 .get(&root.computed_id)
2632 .copied()
2633 .expect("scrollable should have metrics");
2634 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
2635 assert!((metrics.content_h - 360.0).abs() < 0.01);
2636 assert!((metrics.max_offset - 160.0).abs() < 0.01);
2637
2638 let thumb = state
2639 .scroll
2640 .thumb_rects
2641 .get(&root.computed_id)
2642 .copied()
2643 .expect("scrollable with scrollbar() and overflow gets a thumb");
2644 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
2646 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
2647 assert!(thumb.y.abs() < 0.01);
2649 assert!(
2651 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
2652 "thumb anchored at {} (expected {})",
2653 thumb.x,
2654 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
2655 );
2656
2657 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2659 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2660 let thumb = state
2661 .scroll
2662 .thumb_rects
2663 .get(&root.computed_id)
2664 .copied()
2665 .unwrap();
2666 let track_remaining = 200.0 - thumb.h;
2667 let expected_y = track_remaining * (80.0 / 160.0);
2668 assert!(
2669 (thumb.y - expected_y).abs() < 0.5,
2670 "thumb at half-scroll y = {} (expected {expected_y})",
2671 thumb.y,
2672 );
2673 }
2674
2675 #[test]
2676 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
2677 let mut root = scroll(
2681 (0..6)
2682 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2683 )
2684 .gap(12.0)
2685 .height(Size::Fixed(200.0));
2686 let mut state = UiState::new();
2687 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2688
2689 let thumb = state
2690 .scroll
2691 .thumb_rects
2692 .get(&root.computed_id)
2693 .copied()
2694 .unwrap();
2695 let track = state
2696 .scroll
2697 .thumb_tracks
2698 .get(&root.computed_id)
2699 .copied()
2700 .unwrap();
2701 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
2703 assert!(
2704 (track.right() - thumb.right()).abs() < 0.01,
2705 "track and thumb must share the right edge",
2706 );
2707 assert!(
2710 (track.h - 200.0).abs() < 0.01,
2711 "track height = {} (expected 200)",
2712 track.h,
2713 );
2714 }
2715
2716 #[test]
2717 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
2718 let mut suppressed = scroll(
2720 (0..6)
2721 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2722 )
2723 .no_scrollbar()
2724 .height(Size::Fixed(200.0));
2725 let mut state = UiState::new();
2726 layout(
2727 &mut suppressed,
2728 &mut state,
2729 Rect::new(0.0, 0.0, 300.0, 200.0),
2730 );
2731 assert!(
2732 !state
2733 .scroll
2734 .thumb_rects
2735 .contains_key(&suppressed.computed_id)
2736 );
2737
2738 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
2740 .height(Size::Fixed(200.0));
2741 let mut tiny_state = UiState::new();
2742 layout(
2743 &mut tiny,
2744 &mut tiny_state,
2745 Rect::new(0.0, 0.0, 300.0, 200.0),
2746 );
2747 assert!(
2748 !tiny_state
2749 .scroll
2750 .thumb_rects
2751 .contains_key(&tiny.computed_id)
2752 );
2753 }
2754
2755 #[test]
2756 fn layout_override_places_children_at_returned_rects() {
2757 let mut root = column((0..3).map(|i| {
2759 crate::widgets::text::text(format!("dot {i}"))
2760 .width(Size::Fixed(20.0))
2761 .height(Size::Fixed(20.0))
2762 }))
2763 .width(Size::Fixed(200.0))
2764 .height(Size::Fixed(200.0))
2765 .layout(|ctx| {
2766 ctx.children
2767 .iter()
2768 .enumerate()
2769 .map(|(i, _)| {
2770 let off = i as f32 * 30.0;
2771 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
2772 })
2773 .collect()
2774 });
2775 let mut state = UiState::new();
2776 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2777 let r0 = state.rect(&root.children[0].computed_id);
2778 let r1 = state.rect(&root.children[1].computed_id);
2779 let r2 = state.rect(&root.children[2].computed_id);
2780 assert_eq!((r0.x, r0.y), (0.0, 0.0));
2781 assert_eq!((r1.x, r1.y), (30.0, 30.0));
2782 assert_eq!((r2.x, r2.y), (60.0, 60.0));
2783 }
2784
2785 #[test]
2786 fn layout_override_rect_of_key_resolves_earlier_sibling() {
2787 use crate::tree::stack;
2793 let trigger_x = 40.0;
2794 let trigger_y = 20.0;
2795 let trigger_w = 60.0;
2796 let trigger_h = 30.0;
2797 let mut root = stack([
2798 crate::widgets::button::button("Open")
2800 .key("trig")
2801 .width(Size::Fixed(trigger_w))
2802 .height(Size::Fixed(trigger_h)),
2803 stack([crate::widgets::text::text("popover")
2806 .width(Size::Fixed(80.0))
2807 .height(Size::Fixed(20.0))])
2808 .width(Size::Fill(1.0))
2809 .height(Size::Fill(1.0))
2810 .layout(|ctx| {
2811 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
2812 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
2813 }),
2814 ])
2815 .padding(Sides::xy(trigger_x, trigger_y));
2816 let mut state = UiState::new();
2817 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2818
2819 let popover_layer = &root.children[1];
2820 let panel_id = &popover_layer.children[0].computed_id;
2821 let panel_rect = state.rect(panel_id);
2822 assert!(
2825 (panel_rect.x - trigger_x).abs() < 0.01,
2826 "popover x = {} (expected {trigger_x})",
2827 panel_rect.x,
2828 );
2829 assert!(
2830 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
2831 "popover y = {} (expected {})",
2832 panel_rect.y,
2833 trigger_y + trigger_h + 4.0,
2834 );
2835 }
2836
2837 #[test]
2838 fn layout_override_rect_of_key_returns_none_for_missing_key() {
2839 let mut root = column([crate::widgets::text::text("inner")
2840 .width(Size::Fixed(40.0))
2841 .height(Size::Fixed(20.0))])
2842 .width(Size::Fixed(200.0))
2843 .height(Size::Fixed(200.0))
2844 .layout(|ctx| {
2845 assert!((ctx.rect_of_key)("nope").is_none());
2846 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2847 });
2848 let mut state = UiState::new();
2849 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2850 }
2851
2852 #[test]
2853 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
2854 use crate::tree::stack;
2860 let mut root = stack([
2861 stack([crate::widgets::text::text("panel")
2862 .width(Size::Fixed(40.0))
2863 .height(Size::Fixed(20.0))])
2864 .width(Size::Fill(1.0))
2865 .height(Size::Fill(1.0))
2866 .layout(|ctx| {
2867 assert!(
2868 (ctx.rect_of_key)("later").is_none(),
2869 "later sibling's rect must not be available yet"
2870 );
2871 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2872 }),
2873 crate::widgets::button::button("after").key("later"),
2874 ]);
2875 let mut state = UiState::new();
2876 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2877 }
2878
2879 #[test]
2880 fn layout_override_measure_returns_intrinsic() {
2881 let mut root = column([crate::widgets::text::text("hi")
2883 .width(Size::Fixed(40.0))
2884 .height(Size::Fixed(20.0))])
2885 .width(Size::Fixed(200.0))
2886 .height(Size::Fixed(200.0))
2887 .layout(|ctx| {
2888 let (w, h) = (ctx.measure)(&ctx.children[0]);
2889 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
2890 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
2891 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
2892 });
2893 let mut state = UiState::new();
2894 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2895 let r = state.rect(&root.children[0].computed_id);
2896 assert_eq!((r.w, r.h), (40.0, 20.0));
2897 }
2898
2899 #[test]
2900 #[should_panic(expected = "returned 1 rects for 2 children")]
2901 fn layout_override_length_mismatch_panics() {
2902 let mut root = column([
2903 crate::widgets::text::text("a")
2904 .width(Size::Fixed(10.0))
2905 .height(Size::Fixed(10.0)),
2906 crate::widgets::text::text("b")
2907 .width(Size::Fixed(10.0))
2908 .height(Size::Fixed(10.0)),
2909 ])
2910 .width(Size::Fixed(200.0))
2911 .height(Size::Fixed(200.0))
2912 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
2913 let mut state = UiState::new();
2914 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2915 }
2916
2917 #[test]
2918 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
2919 fn layout_override_hug_panics() {
2920 let mut root = column([column([crate::widgets::text::text("c")])
2924 .width(Size::Hug)
2925 .height(Size::Fixed(200.0))
2926 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
2927 .width(Size::Fixed(200.0))
2928 .height(Size::Fixed(200.0));
2929 let mut state = UiState::new();
2930 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2931 }
2932
2933 #[test]
2934 fn virtual_list_realizes_only_visible_rows() {
2935 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
2939 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2940 });
2941 let mut state = UiState::new();
2942 assign_ids(&mut root);
2943 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
2944 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2945
2946 assert_eq!(
2947 root.children.len(),
2948 5,
2949 "expected 5 realized rows, got {}",
2950 root.children.len()
2951 );
2952 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
2954 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
2955 let r0 = state.rect(&root.children[0].computed_id);
2957 assert!(
2958 (r0.y - (-20.0)).abs() < 0.5,
2959 "row 2 expected y≈-20, got {}",
2960 r0.y
2961 );
2962 }
2963
2964 #[test]
2965 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
2966 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
2967 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2968 })
2969 .gap(10.0);
2970 let mut state = UiState::new();
2971 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
2972
2973 assert_eq!(
2974 root.children.len(),
2975 3,
2976 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
2977 );
2978 let row_1 = root
2979 .children
2980 .iter()
2981 .find(|c| c.key.as_deref() == Some("row-1"))
2982 .expect("row 1 should be realized");
2983 assert!(
2984 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
2985 "gap should place row 1 at y=50"
2986 );
2987 let metrics = state
2988 .scroll
2989 .metrics
2990 .get(&root.computed_id)
2991 .expect("virtual list writes scroll metrics");
2992 assert!(
2993 (metrics.content_h - 490.0).abs() < 0.5,
2994 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
2995 metrics.content_h
2996 );
2997 }
2998
2999 #[test]
3000 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3001 let make_root = || {
3002 crate::tree::virtual_list(50, 50.0, |i| {
3003 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3004 })
3005 };
3006
3007 let mut state = UiState::new();
3008 let mut root_a = make_root();
3009 assign_ids(&mut root_a);
3010 state
3012 .scroll
3013 .offsets
3014 .insert(root_a.computed_id.clone(), 250.0);
3015 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3016 let id_at_offset_a = root_a
3017 .children
3018 .iter()
3019 .find(|c| c.key.as_deref() == Some("row-5"))
3020 .unwrap()
3021 .computed_id
3022 .clone();
3023
3024 let mut root_b = make_root();
3026 assign_ids(&mut root_b);
3027 state
3028 .scroll
3029 .offsets
3030 .insert(root_b.computed_id.clone(), 200.0);
3031 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3032 let id_at_offset_b = root_b
3033 .children
3034 .iter()
3035 .find(|c| c.key.as_deref() == Some("row-5"))
3036 .unwrap()
3037 .computed_id
3038 .clone();
3039
3040 assert_eq!(
3041 id_at_offset_a, id_at_offset_b,
3042 "row-5's computed_id changed when scroll offset moved"
3043 );
3044 }
3045
3046 #[test]
3047 fn virtual_list_clamps_overshoot_offset() {
3048 let mut root =
3050 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3051 let mut state = UiState::new();
3052 assign_ids(&mut root);
3053 state
3054 .scroll
3055 .offsets
3056 .insert(root.computed_id.clone(), 9999.0);
3057 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3058 let stored = state
3059 .scroll
3060 .offsets
3061 .get(&root.computed_id)
3062 .copied()
3063 .unwrap_or(0.0);
3064 assert!(
3065 (stored - 300.0).abs() < 0.01,
3066 "expected clamp to 300, got {stored}"
3067 );
3068 }
3069
3070 #[test]
3071 fn virtual_list_empty_count_realizes_no_children() {
3072 let mut root =
3073 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3074 let mut state = UiState::new();
3075 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3076 assert_eq!(root.children.len(), 0);
3077 }
3078
3079 #[test]
3080 #[should_panic(expected = "row_height > 0.0")]
3081 fn virtual_list_zero_row_height_panics() {
3082 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3083 }
3084
3085 #[test]
3086 #[should_panic(expected = "Size::Hug would defeat virtualization")]
3087 fn virtual_list_hug_panics() {
3088 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3089 crate::widgets::text::text(format!("r{i}"))
3090 })
3091 .height(Size::Hug)])
3092 .width(Size::Fixed(300.0))
3093 .height(Size::Fixed(200.0));
3094 let mut state = UiState::new();
3095 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3096 }
3097
3098 #[test]
3099 fn virtual_list_dyn_respects_per_row_fixed_heights() {
3100 let mut root = crate::tree::virtual_list_dyn(
3104 20,
3105 50.0,
3106 |i| format!("row-{i}"),
3107 |i| {
3108 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3109 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3110 .key(format!("row-{i}"))
3111 .height(Size::Fixed(h))
3112 },
3113 );
3114 let mut state = UiState::new();
3115 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3116
3117 assert_eq!(
3118 root.children.len(),
3119 4,
3120 "expected 4 realized rows, got {}",
3121 root.children.len()
3122 );
3123 let ys: Vec<f32> = root
3125 .children
3126 .iter()
3127 .map(|c| state.rect(&c.computed_id).y)
3128 .collect();
3129 assert!(
3130 (ys[0] - 0.0).abs() < 0.5,
3131 "row 0 expected y≈0, got {}",
3132 ys[0]
3133 );
3134 assert!(
3135 (ys[1] - 40.0).abs() < 0.5,
3136 "row 1 expected y≈40, got {}",
3137 ys[1]
3138 );
3139 assert!(
3140 (ys[2] - 120.0).abs() < 0.5,
3141 "row 2 expected y≈120, got {}",
3142 ys[2]
3143 );
3144 assert!(
3145 (ys[3] - 160.0).abs() < 0.5,
3146 "row 3 expected y≈160, got {}",
3147 ys[3]
3148 );
3149 }
3150
3151 #[test]
3152 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3153 let mut root = crate::tree::virtual_list_dyn(
3154 10,
3155 40.0,
3156 |i| format!("row-{i}"),
3157 |i| {
3158 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3159 .key(format!("row-{i}"))
3160 .height(Size::Fixed(40.0))
3161 },
3162 )
3163 .gap(10.0);
3164 let mut state = UiState::new();
3165 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3166
3167 assert_eq!(
3168 root.children.len(),
3169 3,
3170 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3171 );
3172 let row_1 = root
3173 .children
3174 .iter()
3175 .find(|c| c.key.as_deref() == Some("row-1"))
3176 .expect("row 1 should be realized");
3177 assert!(
3178 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3179 "gap should place row 1 at y=50"
3180 );
3181 let metrics = state
3182 .scroll
3183 .metrics
3184 .get(&root.computed_id)
3185 .expect("virtual list writes scroll metrics");
3186 assert!(
3187 (metrics.content_h - 490.0).abs() < 0.5,
3188 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3189 metrics.content_h
3190 );
3191 }
3192
3193 #[test]
3194 fn virtual_list_dyn_caches_measured_heights() {
3195 let mut root = crate::tree::virtual_list_dyn(
3199 50,
3200 50.0,
3201 |i| format!("row-{i}"),
3202 |i| {
3203 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3204 .key(format!("row-{i}"))
3205 .height(Size::Fixed(30.0))
3206 },
3207 );
3208 let mut state = UiState::new();
3209 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3210
3211 let measured = state
3212 .scroll
3213 .measured_row_heights
3214 .get(&root.computed_id)
3215 .expect("dynamic virtual list should populate the height cache");
3216 assert!(
3220 measured.len() >= 6,
3221 "expected ≥ 6 cached row heights, got {}",
3222 measured.len()
3223 );
3224 for by_width in measured.values() {
3225 let h = by_width
3226 .get(&300)
3227 .copied()
3228 .expect("measurement should be keyed at the 300px width bucket");
3229 assert!(
3230 (h - 30.0).abs() < 0.5,
3231 "expected cached height ≈ 30, got {h}"
3232 );
3233 }
3234 }
3235
3236 #[test]
3237 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3238 let make_root = || {
3239 crate::tree::virtual_list_dyn(
3240 100,
3241 40.0,
3242 |i| format!("row-{i}"),
3243 |i| {
3244 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3245 .key(format!("row-{i}"))
3246 .height(Size::Fixed(40.0))
3247 },
3248 )
3249 };
3250 let mut root = make_root();
3251 let mut state = UiState::new();
3252 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3253
3254 state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3255 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3256
3257 let anchor = state
3258 .scroll
3259 .virtual_anchors
3260 .get(&root.computed_id)
3261 .cloned()
3262 .expect("dynamic list should store a visible anchor");
3263 let before_y = root
3264 .children
3265 .iter()
3266 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3267 .map(|child| state.rect(&child.computed_id).y)
3268 .expect("anchor row should be realized");
3269 let before_offset = state.scroll_offset(&root.computed_id);
3270
3271 state
3272 .scroll
3273 .measured_row_heights
3274 .entry(root.computed_id.clone())
3275 .or_default()
3276 .entry("row-0".to_string())
3277 .or_default()
3278 .insert(300, 120.0);
3279
3280 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3281 let after_y = root
3282 .children
3283 .iter()
3284 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3285 .map(|child| state.rect(&child.computed_id).y)
3286 .expect("anchor row should remain realized");
3287 let after_offset = state.scroll_offset(&root.computed_id);
3288
3289 assert!(
3290 (after_y - before_y).abs() < 0.5,
3291 "anchor row should stay at y={before_y}, got {after_y}"
3292 );
3293 assert!(
3294 (after_offset - (before_offset + 80.0)).abs() < 0.5,
3295 "offset should absorb the 80px measurement delta above anchor"
3296 );
3297 }
3298
3299 #[test]
3300 fn virtual_list_dyn_height_cache_is_width_bucketed() {
3301 let mut root = crate::tree::virtual_list_dyn(
3302 20,
3303 50.0,
3304 |i| format!("row-{i}"),
3305 |i| {
3306 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3307 .key(format!("row-{i}"))
3308 .height(Size::Fixed(30.0))
3309 },
3310 );
3311 let mut state = UiState::new();
3312 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3313 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3314
3315 let row_0 = state
3316 .scroll
3317 .measured_row_heights
3318 .get(&root.computed_id)
3319 .and_then(|m| m.get("row-0"))
3320 .expect("row 0 should be measured");
3321 assert!(
3322 row_0.contains_key(&300) && row_0.contains_key(&240),
3323 "expected width buckets 300 and 240, got {:?}",
3324 row_0.keys().collect::<Vec<_>>()
3325 );
3326 }
3327
3328 #[test]
3329 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3330 let make_root = || {
3335 crate::tree::virtual_list_dyn(
3336 20,
3337 50.0,
3338 |i| format!("row-{i}"),
3339 |i| {
3340 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3341 .key(format!("row-{i}"))
3342 .height(Size::Fixed(30.0))
3343 },
3344 )
3345 };
3346 let mut state = UiState::new();
3347 let mut root = make_root();
3348 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3349
3350 state
3351 .scroll
3352 .offsets
3353 .insert(root.computed_id.clone(), 9999.0);
3354 let mut root2 = make_root();
3355 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3356
3357 let measured = state
3358 .scroll
3359 .measured_row_heights
3360 .get(&root2.computed_id)
3361 .expect("dynamic virtual list should populate the height cache");
3362 let measured_sum = measured
3363 .values()
3364 .filter_map(|by_width| by_width.get(&300))
3365 .sum::<f32>();
3366 let measured_count = measured
3367 .values()
3368 .filter(|by_width| by_width.contains_key(&300))
3369 .count();
3370 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3371 let expected_max_offset = expected_total - 200.0;
3372
3373 let stored = state
3374 .scroll
3375 .offsets
3376 .get(&root2.computed_id)
3377 .copied()
3378 .unwrap_or(0.0);
3379 assert!(
3380 (stored - expected_max_offset).abs() < 0.5,
3381 "expected offset clamped to {expected_max_offset}, got {stored}"
3382 );
3383 }
3384
3385 #[test]
3386 fn virtual_list_dyn_empty_count_realizes_no_children() {
3387 let mut root = crate::tree::virtual_list_dyn(
3388 0,
3389 50.0,
3390 |i| format!("row-{i}"),
3391 |i| crate::widgets::text::text(format!("r{i}")),
3392 );
3393 let mut state = UiState::new();
3394 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3395 assert_eq!(root.children.len(), 0);
3396 }
3397
3398 #[test]
3399 #[should_panic(expected = "estimated_row_height > 0.0")]
3400 fn virtual_list_dyn_zero_estimate_panics() {
3401 let _ = crate::tree::virtual_list_dyn(
3402 10,
3403 0.0,
3404 |i| format!("row-{i}"),
3405 |i| crate::widgets::text::text(format!("r{i}")),
3406 );
3407 }
3408
3409 #[test]
3410 fn text_runs_constructor_shape_smoke() {
3411 let el = crate::tree::text_runs([
3412 crate::widgets::text::text("Hello, "),
3413 crate::widgets::text::text("world").bold(),
3414 crate::tree::hard_break(),
3415 crate::widgets::text::text("of text").italic(),
3416 ]);
3417 assert_eq!(el.kind, Kind::Inlines);
3418 assert_eq!(el.children.len(), 4);
3419 assert!(matches!(
3420 el.children[1].font_weight,
3421 FontWeight::Bold | FontWeight::Semibold
3422 ));
3423 assert_eq!(el.children[2].kind, Kind::HardBreak);
3424 assert!(el.children[3].text_italic);
3425 }
3426
3427 #[test]
3428 fn wrapped_text_hugs_multiline_height_from_available_width() {
3429 let mut root = column([crate::paragraph(
3430 "A longer sentence should wrap into multiple measured lines.",
3431 )])
3432 .width(Size::Fill(1.0))
3433 .height(Size::Hug);
3434
3435 let mut state = UiState::new();
3436 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
3437
3438 let child_rect = state.rect(&root.children[0].computed_id);
3439 assert_eq!(child_rect.w, 180.0);
3440 assert!(
3441 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3442 "expected multiline paragraph height, got {}",
3443 child_rect.h
3444 );
3445 }
3446
3447 #[test]
3448 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
3449 const PANEL_W: f32 = 240.0;
3460 const PADDING: f32 = 18.0;
3461 const GAP: f32 = 12.0;
3462
3463 let panel = column([
3464 crate::paragraph(
3465 "A long enough warning paragraph that it has to wrap onto a second line \
3466 inside this narrow panel.",
3467 ),
3468 crate::widgets::button::button("OK").key("ok"),
3469 ])
3470 .width(Size::Fixed(PANEL_W))
3471 .height(Size::Hug)
3472 .padding(Sides::all(PADDING))
3473 .gap(GAP)
3474 .align(Align::Stretch);
3475
3476 let mut root = crate::stack([panel])
3477 .width(Size::Fill(1.0))
3478 .height(Size::Fill(1.0));
3479 let mut state = UiState::new();
3480 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3481
3482 let panel_rect = state.rect(&root.children[0].computed_id);
3483 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
3484
3485 let para_rect = state.rect(&root.children[0].children[0].computed_id);
3486 let button_rect = state.rect(&root.children[0].children[1].computed_id);
3487
3488 assert!(
3491 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3492 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
3493 got h={}",
3494 para_rect.h
3495 );
3496
3497 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
3503 assert!(
3504 (bottom_padding - PADDING).abs() < 0.5,
3505 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
3506 );
3507 }
3508
3509 #[test]
3510 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
3511 const COL_W: f32 = 600.0;
3523 const GUTTER_W: f32 = 3.0;
3524
3525 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
3526 sed do eiusmod tempor incididunt ut labore et dolore magna \
3527 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
3528 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
3529
3530 let make_row = || {
3531 let gutter = El::new(Kind::Custom("gutter"))
3532 .width(Size::Fixed(GUTTER_W))
3533 .height(Size::Fill(1.0));
3534 let body = crate::paragraph(long).width(Size::Fill(1.0));
3535 crate::row([gutter, body]).width(Size::Fill(1.0))
3536 };
3537
3538 let mut root = column([make_row(), make_row()])
3539 .width(Size::Fixed(COL_W))
3540 .height(Size::Hug)
3541 .align(Align::Stretch);
3542 let mut state = UiState::new();
3543 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
3544
3545 let row0_rect = state.rect(&root.children[0].computed_id);
3546 let row1_rect = state.rect(&root.children[1].computed_id);
3547 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
3548
3549 let line_height = crate::tokens::TEXT_SM.line_height;
3554 assert!(
3555 para0_rect.h > line_height * 1.5,
3556 "paragraph should wrap to multiple lines at ~597px wide; \
3557 got h={} (line_height={})",
3558 para0_rect.h,
3559 line_height,
3560 );
3561 assert!(
3562 row0_rect.h > line_height * 1.5,
3563 "row 0 should accommodate the wrapped paragraph height; \
3564 got h={} (line_height={})",
3565 row0_rect.h,
3566 line_height,
3567 );
3568
3569 assert!(
3571 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
3572 "row 1 starts at y={} but row 0 occupies y={}..{}",
3573 row1_rect.y,
3574 row0_rect.y,
3575 row0_rect.y + row0_rect.h,
3576 );
3577 }
3578}