1use std::cell::RefCell;
35use std::sync::Arc;
36
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::scroll::{ScrollAlignment, ScrollRequest};
40use crate::state::{ScrollAnchor, 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::Scene3D => "scene3d",
430 Kind::Custom(name) => name,
431 }
432}
433
434fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
435 if matches!(node.kind, Kind::Inlines) {
436 for c in &mut node.children {
444 ui_state.layout.computed_rects.insert(
445 c.computed_id.clone(),
446 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
447 );
448 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
452 }
453 return;
454 }
455 if let Some(items) = node.virtual_items.clone() {
456 layout_virtual(node, node_rect, items, ui_state);
457 return;
458 }
459 if let Some(layout_fn) = node.layout_override.clone() {
460 layout_custom(node, node_rect, layout_fn, ui_state);
461 if node.scrollable {
462 apply_scroll_offset(node, node_rect, ui_state);
463 }
464 return;
465 }
466 match node.axis {
467 Axis::Overlay => {
468 let inner = node_rect.inset(node.padding);
469 for c in &mut node.children {
470 let c_rect = overlay_rect(c, inner, node.align, node.justify);
471 ui_state
472 .layout
473 .computed_rects
474 .insert(c.computed_id.clone(), c_rect);
475 layout_children(c, c_rect, ui_state);
476 }
477 }
478 Axis::Column => layout_axis(node, node_rect, true, ui_state),
479 Axis::Row => layout_axis(node, node_rect, false, ui_state),
480 }
481 if node.scrollable {
482 apply_scroll_offset(node, node_rect, ui_state);
483 }
484}
485
486fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
487 let inner = node_rect.inset(node.padding);
488 let measure = |c: &El| intrinsic(c);
489 let key_index = &ui_state.layout.key_index;
494 let computed_rects = &ui_state.layout.computed_rects;
495 let rect_of_key = |key: &str| -> Option<Rect> {
496 let id = key_index.get(key)?;
497 computed_rects.get(id).copied()
498 };
499 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
500 let rects = (layout_fn.0)(LayoutCtx {
501 container: inner,
502 children: &node.children,
503 measure: &measure,
504 rect_of_key: &rect_of_key,
505 rect_of_id: &rect_of_id,
506 });
507 assert_eq!(
508 rects.len(),
509 node.children.len(),
510 "LayoutFn for {:?} returned {} rects for {} children",
511 node.computed_id,
512 rects.len(),
513 node.children.len(),
514 );
515 for (c, c_rect) in node.children.iter_mut().zip(rects) {
516 ui_state
517 .layout
518 .computed_rects
519 .insert(c.computed_id.clone(), c_rect);
520 layout_children(c, c_rect, ui_state);
521 }
522}
523
524fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
530 let inner = node_rect.inset(node.padding);
531 match items.mode {
532 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
533 node,
534 inner,
535 items.count,
536 row_height,
537 items.build_row,
538 ui_state,
539 ),
540 VirtualMode::Dynamic {
541 estimated_row_height,
542 } => layout_virtual_dynamic(
543 node,
544 inner,
545 items.count,
546 estimated_row_height,
547 DynamicVirtualFns {
548 anchor_policy: items.anchor_policy,
549 row_key: items.row_key,
550 build_row: items.build_row,
551 },
552 ui_state,
553 ),
554 }
555}
556
557fn resolve_scroll_requests<F, K>(
567 node: &El,
568 inner: Rect,
569 count: usize,
570 row_extent: F,
571 row_for_key: K,
572 ui_state: &mut UiState,
573) -> bool
574where
575 F: Fn(usize) -> (f32, f32),
576 K: Fn(&str) -> Option<usize>,
577{
578 if ui_state.scroll.pending_requests.is_empty() {
579 return false;
580 }
581 let Some(key) = node.key.as_deref() else {
582 return false;
583 };
584 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
585 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
586 pending.into_iter().partition(|req| match req {
587 ScrollRequest::ToRow { list_key, .. } => list_key == key,
588 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
589 ScrollRequest::EnsureVisible { .. } => false,
592 });
593 ui_state.scroll.pending_requests = remaining;
594
595 let mut wrote = false;
596 for req in matched {
597 let (row, align) = match req {
598 ScrollRequest::ToRow { row, align, .. } => (row, align),
599 ScrollRequest::ToRowKey { row_key, align, .. } => {
600 let Some(row) = row_for_key(&row_key) else {
601 continue;
602 };
603 (row, align)
604 }
605 ScrollRequest::EnsureVisible { .. } => continue,
606 };
607 if row >= count {
608 continue;
609 }
610 let (row_top, row_h) = row_extent(row);
611 let row_bottom = row_top + row_h;
612 let viewport_h = inner.h;
613 let current = ui_state
614 .scroll
615 .offsets
616 .get(&node.computed_id)
617 .copied()
618 .unwrap_or(0.0);
619 let new_offset = match align {
620 ScrollAlignment::Start => row_top,
621 ScrollAlignment::End => row_bottom - viewport_h,
622 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
623 ScrollAlignment::Visible => {
624 if row_top < current {
625 row_top
626 } else if row_bottom > current + viewport_h {
627 row_bottom - viewport_h
628 } else {
629 continue;
630 }
631 }
632 };
633 ui_state
634 .scroll
635 .offsets
636 .insert(node.computed_id.clone(), new_offset);
637 wrote = true;
638 }
639 wrote
640}
641
642fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
645 let max_offset = (total_h - inner.h).max(0.0);
646 let stored = ui_state
647 .scroll
648 .offsets
649 .get(&node.computed_id)
650 .copied()
651 .unwrap_or(0.0);
652 let stored = resolve_pin(node, stored, max_offset, ui_state);
653 let offset = stored.clamp(0.0, max_offset);
654 ui_state
655 .scroll
656 .offsets
657 .insert(node.computed_id.clone(), offset);
658 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
659 offset
660}
661
662fn write_virtual_scroll_metrics(
663 node: &El,
664 inner: Rect,
665 total_h: f32,
666 max_offset: f32,
667 offset: f32,
668 ui_state: &mut UiState,
669) {
670 ui_state.scroll.metrics.insert(
671 node.computed_id.clone(),
672 crate::state::ScrollMetrics {
673 viewport_h: inner.h,
674 content_h: total_h,
675 max_offset,
676 },
677 );
678 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
679}
680
681fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
685 let role = role_token(&child.kind);
686 let suffix = match (&child.key, role) {
687 (Some(k), r) => format!("{r}[{k}]"),
688 (None, r) => format!("{r}.{global_i}"),
689 };
690 assign_id(child, &format!("{parent_id}.{suffix}"));
691}
692
693fn layout_virtual_fixed(
694 node: &mut El,
695 inner: Rect,
696 count: usize,
697 row_height: f32,
698 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
699 ui_state: &mut UiState,
700) {
701 let gap = node.gap.max(0.0);
702 let pitch = row_height + gap;
703 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
704 resolve_scroll_requests(
705 node,
706 inner,
707 count,
708 |i| (i as f32 * pitch, row_height),
709 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
710 ui_state,
711 );
712 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
713
714 if count == 0 {
715 node.children.clear();
716 return;
717 }
718
719 let start = (offset / pitch).floor() as usize;
723 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
724
725 let mut realized: Vec<El> = Vec::new();
726 for global_i in start..end {
727 let row_top = global_i as f32 * pitch;
728 if row_top >= offset + inner.h || row_top + row_height <= offset {
729 continue;
730 }
731 let mut child = (build_row)(global_i);
732 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
733
734 let row_y = inner.y + row_top - offset;
735 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
736 ui_state
737 .layout
738 .computed_rects
739 .insert(child.computed_id.clone(), c_rect);
740 layout_children(&mut child, c_rect, ui_state);
741 realized.push(child);
742 }
743 node.children = realized;
744}
745
746fn layout_virtual_dynamic(
747 node: &mut El,
748 inner: Rect,
749 count: usize,
750 estimated_row_height: f32,
751 fns: DynamicVirtualFns,
752 ui_state: &mut UiState,
753) {
754 let gap = node.gap.max(0.0);
755 let width_bucket = virtual_width_bucket(inner.w);
756 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
757 prune_dynamic_measurements(node, &row_keys, ui_state);
758
759 if count == 0 {
760 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
761 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
762 debug_assert_eq!(offset, 0.0);
763 node.children.clear();
764 return;
765 }
766
767 let mut row_heights = dynamic_row_heights(
768 node,
769 &row_keys,
770 width_bucket,
771 estimated_row_height,
772 ui_state,
773 );
774
775 let has_request = node.key.as_deref().is_some_and(|k| {
781 ui_state.scroll.pending_requests.iter().any(|r| match r {
782 ScrollRequest::ToRow { list_key, .. } => list_key == k,
783 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
784 ScrollRequest::EnsureVisible { .. } => false,
785 })
786 });
787 let mut request_wrote = false;
788 if has_request {
789 request_wrote = resolve_scroll_requests(
790 node,
791 inner,
792 count,
793 |target| {
794 (
795 dynamic_row_top(&row_heights, gap, target),
796 row_heights[target],
797 )
798 },
799 |row_key| row_keys.iter().position(|key| key == row_key),
800 ui_state,
801 );
802 }
803
804 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
805 let max_offset = (total_h - inner.h).max(0.0);
806 let stored = ui_state
807 .scroll
808 .offsets
809 .get(&node.computed_id)
810 .copied()
811 .unwrap_or(0.0);
812 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
813 let provisional_offset = if pin_active {
814 match node.pin_policy {
815 crate::tree::PinPolicy::End => max_offset,
816 crate::tree::PinPolicy::Start => 0.0,
817 crate::tree::PinPolicy::None => unreachable!(),
818 }
819 } else if request_wrote {
820 stored
821 } else {
822 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
823 .unwrap_or(stored)
824 }
825 .clamp(0.0, max_offset);
826
827 let (measure_start, _, measure_end) =
828 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
829 measure_dynamic_range(
830 node,
831 DynamicRangeCtx {
832 inner,
833 row_keys: &row_keys,
834 width_bucket,
835 build_row: &fns.build_row,
836 },
837 measure_start,
838 measure_end,
839 ui_state,
840 );
841
842 row_heights = dynamic_row_heights(
843 node,
844 &row_keys,
845 width_bucket,
846 estimated_row_height,
847 ui_state,
848 );
849 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
850 let max_offset = (total_h - inner.h).max(0.0);
851 let stored = ui_state
852 .scroll
853 .offsets
854 .get(&node.computed_id)
855 .copied()
856 .unwrap_or(0.0);
857 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
858 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
859 && ui_state
860 .scroll
861 .pin_active
862 .get(&node.computed_id)
863 .copied()
864 .unwrap_or(false);
865 let mut offset = if pin_active {
866 pin_resolved
867 } else if request_wrote {
868 stored
869 } else {
870 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
871 .unwrap_or(stored)
872 }
873 .clamp(0.0, max_offset);
874
875 ui_state
876 .scroll
877 .offsets
878 .insert(node.computed_id.clone(), offset);
879
880 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
881 let mut realized_rows = layout_dynamic_range(
882 node,
883 DynamicRangeCtx {
884 inner,
885 row_keys: &row_keys,
886 width_bucket,
887 build_row: &fns.build_row,
888 },
889 offset,
890 start,
891 start_y,
892 end,
893 ui_state,
894 );
895
896 row_heights = dynamic_row_heights(
897 node,
898 &row_keys,
899 width_bucket,
900 estimated_row_height,
901 ui_state,
902 );
903 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
904 let max_offset = (total_h - inner.h).max(0.0);
905 let corrected_offset = if pin_active {
906 match node.pin_policy {
907 crate::tree::PinPolicy::End => max_offset,
908 crate::tree::PinPolicy::Start => 0.0,
909 crate::tree::PinPolicy::None => unreachable!(),
910 }
911 } else if request_wrote {
912 offset
913 } else {
914 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
915 .unwrap_or(offset)
916 }
917 .clamp(0.0, max_offset);
918 if (corrected_offset - offset).abs() > 0.01 {
919 let dy = offset - corrected_offset;
920 for child in &node.children {
921 shift_subtree_y(child, dy, ui_state);
922 }
923 for row in &mut realized_rows {
924 row.rect.y += dy;
925 }
926 offset = corrected_offset;
927 ui_state
928 .scroll
929 .offsets
930 .insert(node.computed_id.clone(), offset);
931 }
932 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
933 ui_state
934 .scroll
935 .pin_prev_max
936 .insert(node.computed_id.clone(), max_offset);
937 }
938 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
939
940 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
941 ui_state
942 .scroll
943 .virtual_anchors
944 .insert(node.computed_id.clone(), anchor);
945 } else {
946 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
947 }
948}
949
950struct DynamicVirtualFns {
951 anchor_policy: VirtualAnchorPolicy,
952 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
953 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
954}
955
956#[derive(Clone, Copy)]
957struct DynamicRangeCtx<'a> {
958 inner: Rect,
959 row_keys: &'a [String],
960 width_bucket: u32,
961 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
962}
963
964fn virtual_width_bucket(width: f32) -> u32 {
965 width.max(0.0).round().min(u32::MAX as f32) as u32
966}
967
968fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
969 let Some(measurements) = ui_state
970 .scroll
971 .measured_row_heights
972 .get_mut(&node.computed_id)
973 else {
974 return;
975 };
976 let live_keys = row_keys
977 .iter()
978 .map(String::as_str)
979 .collect::<FxHashSet<_>>();
980 measurements.retain(|key, widths| {
981 let live = live_keys.contains(key.as_str());
982 if live {
983 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
984 }
985 live && !widths.is_empty()
986 });
987 if measurements.is_empty() {
988 ui_state
989 .scroll
990 .measured_row_heights
991 .remove(&node.computed_id);
992 }
993}
994
995fn dynamic_row_heights(
996 node: &El,
997 row_keys: &[String],
998 width_bucket: u32,
999 estimated_row_height: f32,
1000 ui_state: &UiState,
1001) -> Vec<f32> {
1002 let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
1003 row_keys
1004 .iter()
1005 .map(|key| {
1006 measurements
1007 .and_then(|m| m.get(key))
1008 .and_then(|by_width| by_width.get(&width_bucket))
1009 .copied()
1010 .unwrap_or(estimated_row_height)
1011 })
1012 .collect()
1013}
1014
1015fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1016 row_heights
1017 .iter()
1018 .take(target)
1019 .fold(0.0, |y, h| y + *h + gap)
1020}
1021
1022fn dynamic_visible_range(
1023 row_heights: &[f32],
1024 gap: f32,
1025 offset: f32,
1026 viewport_h: f32,
1027) -> (usize, f32, usize) {
1028 let count = row_heights.len();
1029 let mut start = 0;
1030 let mut y = 0.0_f32;
1031 while start < count {
1032 let h = row_heights[start];
1033 if y + h > offset {
1034 break;
1035 }
1036 y += h + gap;
1037 start += 1;
1038 }
1039
1040 let mut end = start;
1041 let mut cursor = y;
1042 let viewport_bottom = offset + viewport_h;
1043 while end < count && cursor < viewport_bottom {
1044 let h = row_heights[end];
1045 end += 1;
1046 cursor += h + gap;
1047 }
1048 (start, y, end)
1049}
1050
1051fn dynamic_anchor_offset(
1052 node: &El,
1053 row_keys: &[String],
1054 row_heights: &[f32],
1055 gap: f32,
1056 stored: f32,
1057 ui_state: &UiState,
1058) -> Option<f32> {
1059 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1060 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1061 anchor.row_index
1062 } else {
1063 row_keys.iter().position(|key| key == &anchor.row_key)?
1064 };
1065 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1066 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1067 let scroll_delta = stored - anchor.resolved_offset;
1068 let viewport_y = anchor.viewport_y - scroll_delta;
1069 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1070}
1071
1072fn measure_dynamic_range(
1073 node: &El,
1074 ctx: DynamicRangeCtx<'_>,
1075 start: usize,
1076 end: usize,
1077 ui_state: &mut UiState,
1078) {
1079 if start >= end {
1080 return;
1081 }
1082 let mut new_measurements = Vec::new();
1083 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1084 let child = (ctx.build_row)(idx);
1085 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1086 new_measurements.push((key.clone(), actual_h));
1087 }
1088 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1089}
1090
1091fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1092 match child.height {
1093 Size::Fixed(v) => v.max(0.0),
1094 Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1095 Size::Aspect(r) => (width * r).max(0.0),
1096 Size::Fill(_) => panic!(
1097 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1098 or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1099 virtualization",
1100 node.computed_id,
1101 ),
1102 }
1103}
1104
1105fn store_dynamic_measurements(
1106 node: &El,
1107 width_bucket: u32,
1108 measurements: Vec<(String, f32)>,
1109 ui_state: &mut UiState,
1110) {
1111 if measurements.is_empty() {
1112 return;
1113 }
1114 let entry = ui_state
1115 .scroll
1116 .measured_row_heights
1117 .entry(node.computed_id.clone())
1118 .or_default();
1119 for (row_key, h) in measurements {
1120 entry.entry(row_key).or_default().insert(width_bucket, h);
1121 }
1122}
1123
1124#[derive(Clone, Debug)]
1125struct DynamicRealizedRow {
1126 index: usize,
1127 key: String,
1128 rect: Rect,
1129}
1130
1131fn layout_dynamic_range(
1132 node: &mut El,
1133 ctx: DynamicRangeCtx<'_>,
1134 offset: f32,
1135 start: usize,
1136 start_y: f32,
1137 end: usize,
1138 ui_state: &mut UiState,
1139) -> Vec<DynamicRealizedRow> {
1140 let gap = node.gap.max(0.0);
1141 let mut cursor_y = start_y;
1142 let mut realized = Vec::new();
1143 let mut realized_rows = Vec::new();
1144 let mut new_measurements = Vec::new();
1145
1146 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1147 let mut child = (ctx.build_row)(idx);
1148 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1149 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1150 new_measurements.push((key.clone(), actual_h));
1151
1152 let row_y = ctx.inner.y + cursor_y - offset;
1153 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1154 ui_state
1155 .layout
1156 .computed_rects
1157 .insert(child.computed_id.clone(), c_rect);
1158 layout_children(&mut child, c_rect, ui_state);
1159
1160 realized_rows.push(DynamicRealizedRow {
1161 index: idx,
1162 key: key.clone(),
1163 rect: c_rect,
1164 });
1165 realized.push(child);
1166 cursor_y += actual_h + gap;
1167 }
1168
1169 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1170 node.children = realized;
1171 realized_rows
1172}
1173
1174fn choose_dynamic_anchor(
1175 policy: VirtualAnchorPolicy,
1176 inner: Rect,
1177 offset: f32,
1178 rows: &[DynamicRealizedRow],
1179) -> Option<VirtualAnchor> {
1180 let visible = rows
1181 .iter()
1182 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1183 .collect::<Vec<_>>();
1184 if visible.is_empty() {
1185 return None;
1186 }
1187
1188 let chosen = match policy {
1189 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1190 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1191 visible
1192 .iter()
1193 .min_by(|a, b| {
1194 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1195 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1196 ad.total_cmp(&bd)
1197 })
1198 .copied()
1199 .map(|row| {
1200 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1201 (row.clone(), anchor_y)
1202 })
1203 }
1204 VirtualAnchorPolicy::FirstVisible => {
1205 let row = visible
1206 .iter()
1207 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1208 .or_else(|| visible.first())
1209 .copied()?;
1210 let anchor_y = row.rect.y.max(inner.y);
1211 Some((row.clone(), anchor_y))
1212 }
1213 VirtualAnchorPolicy::LastVisible => {
1214 let row = visible
1215 .iter()
1216 .rev()
1217 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1218 .or_else(|| visible.last())
1219 .copied()?;
1220 let anchor_y = row.rect.bottom().min(inner.bottom());
1221 Some((row.clone(), anchor_y))
1222 }
1223 }?;
1224
1225 let (row, anchor_y) = chosen;
1226 let row_h = row.rect.h.max(0.0);
1227 let row_fraction = if row_h > 0.0 {
1228 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1229 } else {
1230 0.0
1231 };
1232 Some(VirtualAnchor {
1233 row_key: row.key.clone(),
1234 row_index: row.index,
1235 row_fraction,
1236 viewport_y: anchor_y - inner.y,
1237 resolved_offset: offset,
1238 })
1239}
1240
1241fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1242 if y < top {
1243 top - y
1244 } else if y > bottom {
1245 y - bottom
1246 } else {
1247 0.0
1248 }
1249}
1250
1251fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1252 if count == 0 {
1253 0.0
1254 } else {
1255 row_sum + gap * count.saturating_sub(1) as f32
1256 }
1257}
1258
1259fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1267 let inner = node_rect.inset(node.padding);
1268 if node.children.is_empty() {
1269 ui_state
1270 .scroll
1271 .offsets
1272 .insert(node.computed_id.clone(), 0.0);
1273 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1274 ui_state.scroll.metrics.insert(
1275 node.computed_id.clone(),
1276 crate::state::ScrollMetrics {
1277 viewport_h: inner.h,
1278 content_h: 0.0,
1279 max_offset: 0.0,
1280 },
1281 );
1282 return;
1283 }
1284 let content_bottom = node
1285 .children
1286 .iter()
1287 .map(|c| ui_state.rect(&c.computed_id).bottom())
1288 .fold(f32::NEG_INFINITY, f32::max);
1289 let content_h = (content_bottom - inner.y).max(0.0);
1290 let max_offset = (content_h - inner.h).max(0.0);
1291
1292 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1300
1301 let stored = ui_state
1302 .scroll
1303 .offsets
1304 .get(&node.computed_id)
1305 .copied()
1306 .unwrap_or(0.0);
1307 let stored = resolve_pin(node, stored, max_offset, ui_state);
1308 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1309 && ui_state
1310 .scroll
1311 .pin_active
1312 .get(&node.computed_id)
1313 .copied()
1314 .unwrap_or(false);
1315 let stored = if pin_active || request_wrote {
1316 stored
1317 } else {
1318 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
1319 };
1320 let clamped = stored.clamp(0.0, max_offset);
1321 if clamped > 0.0 {
1322 for c in &node.children {
1323 shift_subtree_y(c, -clamped, ui_state);
1324 }
1325 }
1326 ui_state
1327 .scroll
1328 .offsets
1329 .insert(node.computed_id.clone(), clamped);
1330 ui_state.scroll.metrics.insert(
1331 node.computed_id.clone(),
1332 crate::state::ScrollMetrics {
1333 viewport_h: inner.h,
1334 content_h,
1335 max_offset,
1336 },
1337 );
1338
1339 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1340
1341 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
1342 ui_state
1343 .scroll
1344 .scroll_anchors
1345 .insert(node.computed_id.clone(), anchor);
1346 } else {
1347 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1348 }
1349}
1350
1351fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
1352 let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
1353 let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
1354 if rect.h <= 0.0 {
1355 return None;
1356 }
1357 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
1358 let scroll_delta = stored - anchor.resolved_offset;
1359 let viewport_y = anchor.viewport_y - scroll_delta;
1360 Some(rect.y - inner.y + rect_point - viewport_y)
1361}
1362
1363fn choose_scroll_anchor(
1364 node: &El,
1365 inner: Rect,
1366 offset: f32,
1367 ui_state: &UiState,
1368) -> Option<ScrollAnchor> {
1369 if inner.h <= 0.0 {
1370 return None;
1371 }
1372 let target_y = inner.y + inner.h * 0.25;
1373 let mut best = None;
1374 for child in &node.children {
1375 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
1376 }
1377 let candidate = best?;
1378 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
1379 let rect_fraction = if candidate.rect.h > 0.0 {
1380 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
1381 } else {
1382 0.0
1383 };
1384 Some(ScrollAnchor {
1385 node_id: candidate.node_id,
1386 rect_fraction,
1387 viewport_y: anchor_y - inner.y,
1388 resolved_offset: offset,
1389 })
1390}
1391
1392#[derive(Clone, Debug)]
1393struct ScrollAnchorCandidate {
1394 node_id: String,
1395 rect: Rect,
1396 distance: f32,
1397 depth: usize,
1398}
1399
1400fn choose_scroll_anchor_in_subtree(
1401 node: &El,
1402 inner: Rect,
1403 target_y: f32,
1404 depth: usize,
1405 ui_state: &UiState,
1406 best: &mut Option<ScrollAnchorCandidate>,
1407) {
1408 let Some(rect) = ui_state
1409 .layout
1410 .computed_rects
1411 .get(&node.computed_id)
1412 .copied()
1413 else {
1414 return;
1415 };
1416 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
1417 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
1418 let candidate = ScrollAnchorCandidate {
1419 node_id: node.computed_id.clone(),
1420 rect,
1421 distance,
1422 depth,
1423 };
1424 let replace = best.as_ref().is_none_or(|current| {
1425 candidate.distance < current.distance
1426 || (candidate.distance == current.distance && candidate.depth > current.depth)
1427 || (candidate.distance == current.distance
1428 && candidate.depth == current.depth
1429 && candidate.rect.h < current.rect.h)
1430 });
1431 if replace {
1432 *best = Some(candidate);
1433 }
1434 }
1435
1436 if node.scrollable {
1437 return;
1438 }
1439 for child in &node.children {
1440 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
1441 }
1442}
1443
1444const PIN_EPSILON: f32 = 0.5;
1449
1450fn pin_would_be_active(
1458 node: &El,
1459 stored: f32,
1460 _max_offset: f32,
1461 ui_state: &UiState,
1462) -> Option<bool> {
1463 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1464 match node.pin_policy {
1465 crate::tree::PinPolicy::None => None,
1466 crate::tree::PinPolicy::End => {
1467 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1468 Some(match prev_active {
1469 None => true,
1470 Some(prev) => {
1471 let prev_max = prev_max.unwrap_or(0.0);
1472 if prev && stored < prev_max - PIN_EPSILON {
1473 false
1474 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
1475 true
1476 } else {
1477 prev
1478 }
1479 }
1480 })
1481 }
1482 crate::tree::PinPolicy::Start => Some(match prev_active {
1483 None => true,
1484 Some(prev) => {
1485 if prev && stored > PIN_EPSILON {
1486 false
1487 } else if !prev && stored <= PIN_EPSILON {
1488 true
1489 } else {
1490 prev
1491 }
1492 }
1493 }),
1494 }
1495}
1496
1497fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1510 if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1511 ui_state.scroll.pin_active.remove(&node.computed_id);
1512 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1513 return stored;
1514 }
1515 let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1516 ui_state
1517 .scroll
1518 .pin_active
1519 .insert(node.computed_id.clone(), active);
1520 match node.pin_policy {
1521 crate::tree::PinPolicy::End => {
1522 ui_state
1523 .scroll
1524 .pin_prev_max
1525 .insert(node.computed_id.clone(), max_offset);
1526 if active { max_offset } else { stored }
1527 }
1528 crate::tree::PinPolicy::Start => {
1529 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1533 if active { 0.0 } else { stored }
1534 }
1535 crate::tree::PinPolicy::None => unreachable!(),
1536 }
1537}
1538
1539fn resolve_ensure_visible_for_scroll(
1552 node: &El,
1553 inner: Rect,
1554 content_h: f32,
1555 ui_state: &mut UiState,
1556) -> bool {
1557 if ui_state.scroll.pending_requests.is_empty() {
1558 return false;
1559 }
1560 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1561 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1562 let mut wrote = false;
1563 for req in pending {
1564 let ScrollRequest::EnsureVisible {
1565 container_key,
1566 y,
1567 h,
1568 } = &req
1569 else {
1570 remaining.push(req);
1571 continue;
1572 };
1573 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1574 remaining.push(req);
1579 continue;
1580 };
1581 let inside = node.computed_id == *ancestor_id
1584 || node
1585 .computed_id
1586 .strip_prefix(ancestor_id.as_str())
1587 .is_some_and(|rest| rest.starts_with('.'));
1588 if !inside {
1589 remaining.push(req);
1590 continue;
1591 }
1592 let current = ui_state
1593 .scroll
1594 .offsets
1595 .get(&node.computed_id)
1596 .copied()
1597 .unwrap_or(0.0);
1598 let target_top = *y;
1599 let target_bottom = *y + *h;
1600 let viewport_h = inner.h;
1601 let new_offset = if target_top < current {
1608 target_top
1609 } else if target_bottom > current + viewport_h {
1610 target_bottom - viewport_h
1611 } else {
1612 continue;
1617 };
1618 let max = (content_h - viewport_h).max(0.0);
1622 let new_offset = new_offset.clamp(0.0, max);
1623 ui_state
1624 .scroll
1625 .offsets
1626 .insert(node.computed_id.clone(), new_offset);
1627 wrote = true;
1628 }
1629 ui_state.scroll.pending_requests = remaining;
1630 wrote
1631}
1632
1633fn write_thumb_rect(
1641 node: &El,
1642 inner: Rect,
1643 content_h: f32,
1644 max_offset: f32,
1645 offset: f32,
1646 ui_state: &mut UiState,
1647) {
1648 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1649 return;
1650 }
1651 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1652 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1653 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1654 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1655 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1656 let track_remaining = (inner.h - thumb_h).max(0.0);
1657 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1658 let thumb_x = inner.right() - thumb_w - track_inset;
1659 let track_x = inner.right() - track_w - track_inset;
1660 ui_state.scroll.thumb_rects.insert(
1661 node.computed_id.clone(),
1662 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1663 );
1664 ui_state.scroll.thumb_tracks.insert(
1665 node.computed_id.clone(),
1666 Rect::new(track_x, inner.y, track_w, inner.h),
1667 );
1668}
1669
1670fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1671 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1672 rect.y += dy;
1673 }
1674 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
1675 thumb.y += dy;
1676 }
1677 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
1678 track.y += dy;
1679 }
1680 for c in &node.children {
1681 shift_subtree_y(c, dy, ui_state);
1682 }
1683}
1684
1685fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1686 let inner = node_rect.inset(node.padding);
1687 let n = node.children.len();
1688 if n == 0 {
1689 return;
1690 }
1691
1692 let total_gap = node.gap * n.saturating_sub(1) as f32;
1693 let main_extent = if vertical { inner.h } else { inner.w };
1694 let cross_extent = if vertical { inner.w } else { inner.h };
1695
1696 let intrinsics: Vec<(f32, f32)> = {
1697 crate::profile_span!("layout::axis::intrinsics");
1698 if vertical {
1699 node.children
1704 .iter()
1705 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1706 .collect()
1707 } else {
1708 row_child_intrinsics(node, main_extent)
1718 }
1719 };
1720
1721 let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
1730 let main_intent = if vertical { c.height } else { c.width };
1731 if let Size::Aspect(r) = main_intent {
1732 let cross_intent = if vertical { c.width } else { c.height };
1733 if !matches!(cross_intent, Size::Aspect(_)) {
1734 let cross_intrinsic = if vertical { iw } else { ih };
1735 let cross_size = match cross_intent {
1736 Size::Fixed(v) => v,
1737 Size::Hug | Size::Fill(_) => match node.align {
1738 Align::Stretch => cross_extent,
1739 Align::Start | Align::Center | Align::End => cross_intrinsic,
1740 },
1741 Size::Aspect(_) => unreachable!(),
1742 };
1743 let cross_size = if vertical {
1744 clamp_w(c, cross_size)
1745 } else {
1746 clamp_h(c, cross_size)
1747 };
1748 let main = cross_size * r.max(0.0);
1749 let clamped = if vertical {
1750 clamp_h(c, main)
1751 } else {
1752 clamp_w(c, main)
1753 };
1754 return MainSize::Resolved(clamped);
1755 }
1756 }
1757 main_size_of(c, iw, ih, vertical)
1758 };
1759
1760 let mut consumed = 0.0;
1761 let mut fill_weight_total = 0.0;
1762 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1763 match resolve_main(c, *iw, *ih) {
1764 MainSize::Resolved(v) => consumed += v,
1765 MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1766 }
1767 }
1768 let remaining = (main_extent - consumed - total_gap).max(0.0);
1769
1770 let free_after_used = if fill_weight_total == 0.0 {
1774 remaining
1775 } else {
1776 0.0
1777 };
1778 let mut cursor = match node.justify {
1779 Justify::Start => 0.0,
1780 Justify::Center => free_after_used * 0.5,
1781 Justify::End => free_after_used,
1782 Justify::SpaceBetween => 0.0,
1783 };
1784 let between_extra =
1785 if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1786 remaining / (n - 1) as f32
1787 } else {
1788 0.0
1789 };
1790 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1791
1792 crate::profile_span!("layout::axis::place");
1793 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1794 let main_size = match resolve_main(c, iw, ih) {
1795 MainSize::Resolved(v) => v,
1796 MainSize::Fill(w) => {
1797 let raw = remaining * w.max(0.001) / fill_weight_total.max(0.001);
1798 if vertical {
1799 clamp_h(c, raw)
1800 } else {
1801 clamp_w(c, raw)
1802 }
1803 }
1804 };
1805
1806 let cross_intent = if vertical { c.width } else { c.height };
1807 let cross_intrinsic = if vertical { iw } else { ih };
1808 let cross_size = match cross_intent {
1820 Size::Fixed(v) => v,
1821 Size::Aspect(r) => main_size * r,
1822 Size::Hug | Size::Fill(_) => match node.align {
1823 Align::Stretch => cross_extent,
1824 Align::Start | Align::Center | Align::End => cross_intrinsic,
1825 },
1826 };
1827 let cross_size = if vertical {
1828 clamp_w(c, cross_size)
1829 } else {
1830 clamp_h(c, cross_size)
1831 };
1832
1833 let cross_off = match node.align {
1834 Align::Start | Align::Stretch => 0.0,
1835 Align::Center => (cross_extent - cross_size) * 0.5,
1836 Align::End => cross_extent - cross_size,
1837 };
1838
1839 let c_rect = if vertical {
1840 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1841 } else {
1842 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1843 };
1844 ui_state
1845 .layout
1846 .computed_rects
1847 .insert(c.computed_id.clone(), c_rect);
1848 if can_prune_scroll_child(c, c_rect, scroll_visible) {
1849 let nodes = zero_descendant_rects(c, c_rect, ui_state);
1850 record_pruned_subtree(nodes);
1851 } else {
1852 layout_children(c, c_rect, ui_state);
1853 }
1854
1855 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1856 }
1857}
1858
1859const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1860
1861fn scroll_visible_content_rect(
1862 node: &El,
1863 inner: Rect,
1864 vertical: bool,
1865 ui_state: &UiState,
1866) -> Option<Rect> {
1867 if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1868 return None;
1869 }
1870 let offset = ui_state
1871 .scroll
1872 .offsets
1873 .get(&node.computed_id)
1874 .copied()
1875 .unwrap_or(0.0)
1876 .max(0.0);
1877 Some(Rect::new(
1878 inner.x,
1879 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1880 inner.w,
1881 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1882 ))
1883}
1884
1885fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1886 let Some(visible) = visible else {
1887 return false;
1888 };
1889 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1890}
1891
1892fn subtree_is_layout_confined(node: &El) -> bool {
1893 if node.translate != (0.0, 0.0)
1894 || node.scale != 1.0
1895 || node.shadow > 0.0
1896 || node.paint_overflow != Sides::zero()
1897 || node.hit_overflow != Sides::zero()
1898 || node.layout_override.is_some()
1899 || node.virtual_items.is_some()
1900 {
1901 return false;
1902 }
1903 node.children.iter().all(subtree_is_layout_confined)
1904}
1905
1906fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1907 let mut count = 0;
1908 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1909 for child in &node.children {
1910 ui_state
1911 .layout
1912 .computed_rects
1913 .insert(child.computed_id.clone(), zero);
1914 count += 1 + zero_descendant_rects(child, zero, ui_state);
1915 }
1916 count
1917}
1918
1919fn record_pruned_subtree(nodes: u64) {
1920 INTRINSIC_CACHE.with(|cell| {
1921 if let Some(cache) = cell.borrow_mut().as_mut() {
1922 cache.prune.subtrees += 1;
1923 cache.prune.nodes += nodes;
1924 }
1925 });
1926}
1927
1928enum MainSize {
1929 Resolved(f32),
1930 Fill(f32),
1931}
1932
1933fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1934 let s = if vertical { c.height } else { c.width };
1935 let intr = if vertical { ih } else { iw };
1936 let clamp = |v: f32| {
1937 if vertical {
1938 clamp_h(c, v)
1939 } else {
1940 clamp_w(c, v)
1941 }
1942 };
1943 match s {
1944 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
1945 Size::Hug => MainSize::Resolved(clamp(intr)),
1946 Size::Fill(w) => MainSize::Fill(w),
1947 Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
1954 }
1955}
1956
1957fn child_intrinsic(
1958 c: &El,
1959 vertical: bool,
1960 parent_cross_extent: f32,
1961 parent_align: Align,
1962) -> (f32, f32) {
1963 if !vertical {
1964 return intrinsic(c);
1965 }
1966 let available_width = match c.width {
1967 Size::Fixed(v) => Some(v),
1968 Size::Fill(_) => Some(parent_cross_extent),
1969 Size::Hug => match parent_align {
1970 Align::Stretch => Some(parent_cross_extent),
1971 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1972 },
1973 Size::Aspect(_) => Some(parent_cross_extent),
1979 };
1980 intrinsic_constrained(c, available_width)
1981}
1982
1983fn row_child_intrinsics(node: &El, inner_main_extent: f32) -> Vec<(f32, f32)> {
1992 let n = node.children.len();
1993 let total_gap = node.gap * n.saturating_sub(1) as f32;
1994
1995 let mut first: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1996 let mut consumed: f32 = 0.0;
1997 let mut fill_weight_total: f32 = 0.0;
1998 for c in &node.children {
1999 match c.width {
2000 Size::Fill(w) => {
2001 fill_weight_total += w.max(0.001);
2002 first.push(None);
2003 }
2004 _ => {
2005 let (iw, ih) = intrinsic(c);
2006 consumed += iw;
2007 first.push(Some((iw, ih)));
2008 }
2009 }
2010 }
2011
2012 let fill_remaining = (inner_main_extent - consumed - total_gap).max(0.0);
2013
2014 node.children
2015 .iter()
2016 .zip(first)
2017 .map(|(c, slot)| match slot {
2018 Some(rc) => rc,
2019 None => {
2020 let weight = match c.width {
2021 Size::Fill(w) => w.max(0.001),
2022 _ => 1.0,
2023 };
2024 let av = if fill_weight_total > 0.0 {
2025 fill_remaining * weight / fill_weight_total
2026 } else {
2027 fill_remaining
2028 };
2029 intrinsic_constrained(c, Some(av))
2030 }
2031 })
2032 .collect()
2033}
2034
2035fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
2036 let constrained_width = match c.width {
2043 Size::Fixed(v) => Some(v),
2044 Size::Fill(_) | Size::Hug => Some(parent.w),
2045 Size::Aspect(_) => None,
2048 };
2049 let (iw, ih) = intrinsic_constrained(c, constrained_width);
2050 let (w, h) = match (c.width, c.height) {
2055 (Size::Aspect(_), Size::Aspect(_)) => (iw.min(parent.w), ih.min(parent.h)),
2056 (Size::Aspect(r), _) => {
2057 let h = match c.height {
2058 Size::Fixed(v) => v,
2059 Size::Hug => ih.min(parent.h),
2060 Size::Fill(_) => parent.h,
2061 Size::Aspect(_) => unreachable!(),
2062 };
2063 (h * r, h)
2064 }
2065 (_, Size::Aspect(r)) => {
2066 let w = match c.width {
2067 Size::Fixed(v) => v,
2068 Size::Hug => iw.min(parent.w),
2069 Size::Fill(_) => parent.w,
2070 Size::Aspect(_) => unreachable!(),
2071 };
2072 (w, w * r)
2073 }
2074 _ => {
2075 let w = match c.width {
2076 Size::Fixed(v) => v,
2077 Size::Hug => iw.min(parent.w),
2078 Size::Fill(_) => parent.w,
2079 Size::Aspect(_) => unreachable!(),
2080 };
2081 let h = match c.height {
2082 Size::Fixed(v) => v,
2083 Size::Hug => ih.min(parent.h),
2084 Size::Fill(_) => parent.h,
2085 Size::Aspect(_) => unreachable!(),
2086 };
2087 (w, h)
2088 }
2089 };
2090 let w = clamp_w(c, w);
2091 let h = clamp_h(c, h);
2092 let x = match align {
2093 Align::Start | Align::Stretch => parent.x,
2094 Align::Center => parent.x + (parent.w - w) * 0.5,
2095 Align::End => parent.right() - w,
2096 };
2097 let y = match justify {
2098 Justify::Start | Justify::SpaceBetween => parent.y,
2099 Justify::Center => parent.y + (parent.h - h) * 0.5,
2100 Justify::End => parent.bottom() - h,
2101 };
2102 Rect::new(x, y, w, h)
2103}
2104
2105pub fn intrinsic(c: &El) -> (f32, f32) {
2107 intrinsic_constrained(c, None)
2108}
2109
2110fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
2111 let available_width = match c.width {
2117 Size::Fixed(v) => Some(v),
2118 _ => available_width,
2119 };
2120 let key = intrinsic_cache_key(c, available_width);
2121 if let Some(key) = &key
2122 && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
2123 let mut slot = cell.borrow_mut();
2124 let cache = slot.as_mut()?;
2125 let cached = cache.measurements.get(key).copied();
2126 if cached.is_some() {
2127 cache.stats.hits += 1;
2128 }
2129 cached
2130 })
2131 {
2132 return cached;
2133 }
2134
2135 if key.is_some() {
2136 INTRINSIC_CACHE.with(|cell| {
2137 if let Some(cache) = cell.borrow_mut().as_mut() {
2138 cache.stats.misses += 1;
2139 }
2140 });
2141 }
2142
2143 let measured = apply_aspect(
2144 c,
2145 available_width,
2146 intrinsic_constrained_uncached(c, available_width),
2147 );
2148
2149 if let Some(key) = key {
2150 INTRINSIC_CACHE.with(|cell| {
2151 if let Some(cache) = cell.borrow_mut().as_mut() {
2152 cache.measurements.insert(key, measured);
2153 }
2154 });
2155 }
2156
2157 measured
2158}
2159
2160fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
2175 match (c.width, c.height) {
2176 (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
2177 (Size::Aspect(r), _) => {
2178 (clamp_w(c, ih * r.max(0.0)), ih)
2183 }
2184 (_, Size::Aspect(r)) => {
2185 let raw_basis = match c.width {
2186 Size::Fixed(v) => v,
2187 Size::Fill(_) => available_width.unwrap_or(iw),
2188 Size::Hug | Size::Aspect(_) => iw,
2189 };
2190 let basis = clamp_w(c, raw_basis);
2194 (iw, clamp_h(c, basis * r.max(0.0)))
2195 }
2196 _ => (iw, ih),
2197 }
2198}
2199
2200fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
2201 if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
2202 return None;
2203 }
2204 if c.computed_id.is_empty() {
2205 return None;
2206 }
2207 Some(IntrinsicCacheKey {
2208 computed_id: c.computed_id.clone(),
2209 available_width_bits: available_width.map(f32::to_bits),
2210 })
2211}
2212
2213fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
2214 if c.layout_override.is_some() {
2215 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2220 panic!(
2221 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
2222 Size::Hug is not supported for custom layouts",
2223 c.computed_id,
2224 );
2225 }
2226 return apply_min(c, 0.0, 0.0);
2227 }
2228 if c.virtual_items.is_some() {
2229 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2234 panic!(
2235 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
2236 Size::Hug would defeat virtualization",
2237 c.computed_id,
2238 );
2239 }
2240 return apply_min(c, 0.0, 0.0);
2241 }
2242 if matches!(c.kind, Kind::Inlines) {
2243 return inline_paragraph_intrinsic(c, available_width);
2244 }
2245 if matches!(c.kind, Kind::HardBreak) {
2246 return apply_min(c, 0.0, 0.0);
2250 }
2251 if matches!(c.kind, Kind::Math) {
2252 if let Some(expr) = &c.math {
2253 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
2254 return apply_min(
2255 c,
2256 layout.width + c.padding.left + c.padding.right,
2257 layout.height() + c.padding.top + c.padding.bottom,
2258 );
2259 }
2260 return apply_min(c, 0.0, 0.0);
2261 }
2262 if c.icon.is_some() {
2263 return apply_min(
2264 c,
2265 c.font_size + c.padding.left + c.padding.right,
2266 c.font_size + c.padding.top + c.padding.bottom,
2267 );
2268 }
2269 if let Some(img) = &c.image {
2270 let w = img.width() as f32 + c.padding.left + c.padding.right;
2274 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2275 return apply_min(c, w, h);
2276 }
2277 if let Some(text) = &c.text {
2278 let content_available = match c.text_wrap {
2279 TextWrap::NoWrap => None,
2280 TextWrap::Wrap => available_width
2281 .or(match c.width {
2282 Size::Fixed(v) => Some(v),
2283 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2287 })
2288 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2289 };
2290 let display = display_text_for_measure(c, text, content_available);
2291 let layout = text_metrics::layout_text_with_line_height_and_family(
2292 &display,
2293 c.font_size,
2294 c.line_height,
2295 c.font_family,
2296 c.font_weight,
2297 c.font_mono,
2298 c.text_wrap,
2299 content_available,
2300 );
2301 let w = match (content_available, c.width) {
2302 (Some(available), Size::Hug | Size::Aspect(_)) => {
2303 let unwrapped = text_metrics::layout_text_with_family(
2304 text,
2305 c.font_size,
2306 c.font_family,
2307 c.font_weight,
2308 c.font_mono,
2309 TextWrap::NoWrap,
2310 None,
2311 );
2312 unwrapped.width.min(available) + c.padding.left + c.padding.right
2313 }
2314 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2315 available + c.padding.left + c.padding.right
2316 }
2317 (None, _) => layout.width + c.padding.left + c.padding.right,
2318 };
2319 let h = layout.height + c.padding.top + c.padding.bottom;
2320 return apply_min(c, w, h);
2321 }
2322 match c.axis {
2323 Axis::Overlay => {
2324 let mut w: f32 = 0.0;
2325 let mut h: f32 = 0.0;
2326 for ch in &c.children {
2327 let child_available =
2328 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2329 let (cw, chh) = intrinsic_constrained(ch, child_available);
2330 w = w.max(cw);
2331 h = h.max(chh);
2332 }
2333 apply_min(
2334 c,
2335 w + c.padding.left + c.padding.right,
2336 h + c.padding.top + c.padding.bottom,
2337 )
2338 }
2339 Axis::Column => {
2340 let mut w: f32 = 0.0;
2341 let mut h: f32 = c.padding.top + c.padding.bottom;
2342 let n = c.children.len();
2343 let child_available =
2344 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2345 for (i, ch) in c.children.iter().enumerate() {
2346 let (cw, chh) = intrinsic_constrained(ch, child_available);
2347 w = w.max(cw);
2348 h += chh;
2349 if i + 1 < n {
2350 h += c.gap;
2351 }
2352 }
2353 apply_min(c, w + c.padding.left + c.padding.right, h)
2354 }
2355 Axis::Row => {
2356 let n = c.children.len();
2366 let total_gap = c.gap * n.saturating_sub(1) as f32;
2367 let inner_available = available_width
2368 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2369
2370 let mut consumed: f32 = 0.0;
2376 let mut fill_weight_total: f32 = 0.0;
2377 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2378 for ch in &c.children {
2379 match ch.width {
2380 Size::Fill(w) => {
2381 fill_weight_total += w.max(0.001);
2382 sizes.push(None);
2383 }
2384 _ => {
2385 let (cw, chh) = intrinsic(ch);
2386 consumed += cw;
2387 sizes.push(Some((cw, chh)));
2388 }
2389 }
2390 }
2391
2392 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2400 let mut w_total: f32 = c.padding.left + c.padding.right;
2401 let mut h_max: f32 = 0.0;
2402 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2403 let (cw, chh) = match slot {
2404 Some(rc) => rc,
2405 None => match (fill_remaining, fill_weight_total > 0.0) {
2406 (Some(av), true) => {
2407 let weight = match ch.width {
2408 Size::Fill(w) => w.max(0.001),
2409 _ => 1.0,
2410 };
2411 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2412 }
2413 _ => intrinsic(ch),
2414 },
2415 };
2416 w_total += cw;
2417 if i + 1 < n {
2418 w_total += c.gap;
2419 }
2420 h_max = h_max.max(chh);
2421 }
2422 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2423 }
2424 }
2425}
2426
2427pub(crate) fn text_layout(
2428 c: &El,
2429 available_width: Option<f32>,
2430) -> Option<text_metrics::TextLayout> {
2431 let text = c.text.as_ref()?;
2432 let content_available = match c.text_wrap {
2433 TextWrap::NoWrap => None,
2434 TextWrap::Wrap => available_width
2435 .or(match c.width {
2436 Size::Fixed(v) => Some(v),
2437 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2438 })
2439 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2440 };
2441 let display = display_text_for_measure(c, text, content_available);
2442 Some(text_metrics::layout_text_with_line_height_and_family(
2443 &display,
2444 c.font_size,
2445 c.line_height,
2446 c.font_family,
2447 c.font_weight,
2448 c.font_mono,
2449 c.text_wrap,
2450 content_available,
2451 ))
2452}
2453
2454fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2455 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2456 (c.text_wrap, c.text_max_lines, available_width)
2457 {
2458 text_metrics::clamp_text_to_lines_with_family(
2459 text,
2460 c.font_size,
2461 c.font_family,
2462 c.font_weight,
2463 c.font_mono,
2464 width,
2465 max_lines,
2466 )
2467 } else {
2468 text.to_string()
2469 }
2470}
2471
2472fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2473 if let Size::Fixed(v) = c.width {
2474 w = v;
2475 }
2476 if let Size::Fixed(v) = c.height {
2477 h = v;
2478 }
2479 (clamp_w(c, w), clamp_h(c, h))
2480}
2481
2482pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2488 if let Some(max_w) = c.max_width {
2489 w = w.min(max_w);
2490 }
2491 if let Some(min_w) = c.min_width {
2492 w = w.max(min_w);
2493 }
2494 w.max(0.0)
2495}
2496
2497pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2499 if let Some(max_h) = c.max_height {
2500 h = h.min(max_h);
2501 }
2502 if let Some(min_h) = c.min_height {
2503 h = h.max(min_h);
2504 }
2505 h.max(0.0)
2506}
2507
2508fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2521 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2522 return inline_mixed_intrinsic(node, available_width);
2523 }
2524 let concat = concat_inline_text(&node.children);
2525 let size = inline_paragraph_size(node);
2526 let line_height = inline_paragraph_line_height(node);
2527 let content_available = match node.text_wrap {
2528 TextWrap::NoWrap => None,
2529 TextWrap::Wrap => available_width
2530 .or(match node.width {
2531 Size::Fixed(v) => Some(v),
2532 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2533 })
2534 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2535 };
2536 let layout = text_metrics::layout_text_with_line_height_and_family(
2537 &concat,
2538 size,
2539 line_height,
2540 node.font_family,
2541 FontWeight::Regular,
2542 false,
2543 node.text_wrap,
2544 content_available,
2545 );
2546 let w = match (content_available, node.width) {
2547 (Some(available), Size::Hug | Size::Aspect(_)) => {
2548 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2549 &concat,
2550 size,
2551 line_height,
2552 node.font_family,
2553 FontWeight::Regular,
2554 false,
2555 TextWrap::NoWrap,
2556 None,
2557 );
2558 unwrapped.width.min(available) + node.padding.left + node.padding.right
2559 }
2560 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2561 available + node.padding.left + node.padding.right
2562 }
2563 (None, _) => layout.width + node.padding.left + node.padding.right,
2564 };
2565 let h = layout.height + node.padding.top + node.padding.bottom;
2566 apply_min(node, w, h)
2567}
2568
2569fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2570 let wrap_width = match node.text_wrap {
2571 TextWrap::Wrap => available_width.or(match node.width {
2572 Size::Fixed(v) => Some(v),
2573 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2574 }),
2575 TextWrap::NoWrap => None,
2576 }
2577 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2578
2579 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2580 node.text_wrap,
2581 wrap_width,
2582 node.font_size * 0.82,
2583 node.font_size * 0.22,
2584 node.line_height,
2585 );
2586
2587 for child in &node.children {
2588 match child.kind {
2589 Kind::HardBreak => {
2590 breaker.finish_line();
2591 continue;
2592 }
2593 Kind::Text => {
2594 let text = child.text.as_deref().unwrap_or("");
2595 for chunk in inline_text_chunks(text) {
2596 let is_space = chunk.chars().all(char::is_whitespace);
2597 if breaker.skips_leading_space(is_space) {
2598 continue;
2599 }
2600 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2601 if breaker.wraps_before(is_space, w) {
2602 breaker.finish_line();
2603 }
2604 if breaker.skips_overflowing_space(is_space, w) {
2605 continue;
2606 }
2607 breaker.push(w, ascent, descent);
2608 }
2609 continue;
2610 }
2611 _ => {}
2612 }
2613 let (w, ascent, descent) = inline_child_metrics(child);
2614 if breaker.wraps_before(false, w) {
2615 breaker.finish_line();
2616 }
2617 breaker.push(w, ascent, descent);
2618 }
2619 let measurement = breaker.finish();
2620 let w = measurement.width + node.padding.left + node.padding.right;
2621 let h = measurement.height + node.padding.top + node.padding.bottom;
2622 apply_min(node, w, h)
2623}
2624
2625fn inline_text_chunks(text: &str) -> Vec<&str> {
2626 let mut chunks = Vec::new();
2627 let mut start = 0;
2628 let mut last_space = None;
2629 for (i, ch) in text.char_indices() {
2630 let is_space = ch.is_whitespace();
2631 match last_space {
2632 None => last_space = Some(is_space),
2633 Some(prev) if prev != is_space => {
2634 chunks.push(&text[start..i]);
2635 start = i;
2636 last_space = Some(is_space);
2637 }
2638 _ => {}
2639 }
2640 }
2641 if start < text.len() {
2642 chunks.push(&text[start..]);
2643 }
2644 chunks
2645}
2646
2647fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2648 let layout = text_metrics::layout_text_with_line_height_and_family(
2649 text,
2650 child.font_size,
2651 child.line_height,
2652 child.font_family,
2653 child.font_weight,
2654 child.font_mono,
2655 TextWrap::NoWrap,
2656 None,
2657 );
2658 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2659}
2660
2661fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2662 match child.kind {
2663 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2664 Kind::Math => {
2665 if let Some(expr) = &child.math {
2666 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2667 (layout.width, layout.ascent, layout.descent)
2668 } else {
2669 (0.0, 0.0, 0.0)
2670 }
2671 }
2672 _ => (0.0, 0.0, 0.0),
2673 }
2674}
2675
2676fn concat_inline_text(children: &[El]) -> String {
2683 let mut s = String::new();
2684 for c in children {
2685 match c.kind {
2686 Kind::Text => {
2687 if let Some(t) = &c.text {
2688 s.push_str(t);
2689 }
2690 }
2691 Kind::HardBreak => s.push('\n'),
2692 _ => {}
2693 }
2694 }
2695 s
2696}
2697
2698fn inline_paragraph_size(node: &El) -> f32 {
2702 let mut size: f32 = node.font_size;
2703 for c in &node.children {
2704 if matches!(c.kind, Kind::Text) {
2705 size = size.max(c.font_size);
2706 }
2707 }
2708 size
2709}
2710
2711fn inline_paragraph_line_height(node: &El) -> f32 {
2712 let mut line_height: f32 = node.line_height;
2713 let mut max_size: f32 = node.font_size;
2714 for c in &node.children {
2715 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2716 max_size = c.font_size;
2717 line_height = c.line_height;
2718 }
2719 }
2720 line_height
2721}
2722
2723#[cfg(test)]
2724mod tests {
2725 use super::*;
2726 use crate::state::UiState;
2727
2728 #[test]
2733 fn align_center_shrinks_fill_child_to_intrinsic() {
2734 let mut root = column([crate::row([crate::widgets::text::text("hi")
2738 .width(Size::Fixed(40.0))
2739 .height(Size::Fixed(20.0))])])
2740 .align(Align::Center)
2741 .width(Size::Fixed(200.0))
2742 .height(Size::Fixed(100.0));
2743 let mut state = UiState::new();
2744 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2745 let row_rect = state.rect(&root.children[0].computed_id);
2746 assert!(
2749 (row_rect.x - 80.0).abs() < 0.5,
2750 "expected x≈80 (centered), got {}",
2751 row_rect.x
2752 );
2753 assert!(
2754 (row_rect.w - 40.0).abs() < 0.5,
2755 "expected w≈40 (shrunk to intrinsic), got {}",
2756 row_rect.w
2757 );
2758 }
2759
2760 #[test]
2763 fn align_stretch_preserves_fill_stretch() {
2764 let mut root = column([crate::row([crate::widgets::text::text("hi")
2765 .width(Size::Fixed(40.0))
2766 .height(Size::Fixed(20.0))])])
2767 .align(Align::Stretch)
2768 .width(Size::Fixed(200.0))
2769 .height(Size::Fixed(100.0));
2770 let mut state = UiState::new();
2771 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2772 let row_rect = state.rect(&root.children[0].computed_id);
2773 assert!(
2774 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2775 "expected stretched (x=0, w=200), got x={} w={}",
2776 row_rect.x,
2777 row_rect.w
2778 );
2779 }
2780
2781 #[test]
2788 fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
2789 let long = "The quick brown fox jumps over the lazy dog, then \
2790 does it again and again until the line is long \
2791 enough to wrap several times.";
2792 let mut root = column([column([column([crate::widgets::text::text(long)
2796 .wrap_text()
2797 .width(Size::Fixed(200.0))])
2798 .width(Size::Fixed(240.0))])
2799 .width(Size::Fill(1.0))]);
2800 let mut state = UiState::new();
2801 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
2802 let card = state.rect(&root.children[0].computed_id);
2803 let text_rect = state.rect(&root.children[0].children[0].children[0].computed_id);
2804 assert!(
2805 text_rect.h > 25.0,
2806 "text should wrap to multiple lines at 200px, got h={}",
2807 text_rect.h
2808 );
2809 assert!(
2810 (card.h - text_rect.h).abs() < 0.5,
2811 "Hug card height {} must match wrapped text height {}",
2812 card.h,
2813 text_rect.h
2814 );
2815 }
2816
2817 #[test]
2820 fn justify_center_centers_hug_children() {
2821 let mut root = column([crate::widgets::text::text("hi")
2822 .width(Size::Fixed(40.0))
2823 .height(Size::Fixed(20.0))])
2824 .justify(Justify::Center)
2825 .height(Size::Fill(1.0));
2826 let mut state = UiState::new();
2827 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2828 let child_rect = state.rect(&root.children[0].computed_id);
2829 assert!(
2831 (child_rect.y - 40.0).abs() < 0.5,
2832 "expected y≈40, got {}",
2833 child_rect.y
2834 );
2835 }
2836
2837 #[test]
2838 fn justify_end_pushes_to_bottom() {
2839 let mut root = column([crate::widgets::text::text("hi")
2840 .width(Size::Fixed(40.0))
2841 .height(Size::Fixed(20.0))])
2842 .justify(Justify::End)
2843 .height(Size::Fill(1.0));
2844 let mut state = UiState::new();
2845 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2846 let child_rect = state.rect(&root.children[0].computed_id);
2847 assert!(
2848 (child_rect.y - 80.0).abs() < 0.5,
2849 "expected y≈80, got {}",
2850 child_rect.y
2851 );
2852 }
2853
2854 #[test]
2858 fn justify_space_between_distributes_evenly() {
2859 let row_child = || {
2860 crate::widgets::text::text("x")
2861 .width(Size::Fixed(20.0))
2862 .height(Size::Fixed(20.0))
2863 };
2864 let mut root = column([row_child(), row_child(), row_child()])
2865 .justify(Justify::SpaceBetween)
2866 .height(Size::Fixed(200.0));
2867 let mut state = UiState::new();
2868 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2869 let y0 = state.rect(&root.children[0].computed_id).y;
2872 let y1 = state.rect(&root.children[1].computed_id).y;
2873 let y2 = state.rect(&root.children[2].computed_id).y;
2874 assert!(
2875 y0.abs() < 0.5,
2876 "first child should be flush at y=0, got {y0}"
2877 );
2878 assert!(
2879 (y1 - 90.0).abs() < 0.5,
2880 "middle child should be at y≈90, got {y1}"
2881 );
2882 assert!(
2883 (y2 - 180.0).abs() < 0.5,
2884 "last child should be flush at y≈180, got {y2}"
2885 );
2886 }
2887
2888 #[test]
2892 fn fill_weight_distributes_proportionally() {
2893 let big = crate::widgets::text::text("big")
2894 .width(Size::Fixed(40.0))
2895 .height(Size::Fill(2.0));
2896 let small = crate::widgets::text::text("small")
2897 .width(Size::Fixed(40.0))
2898 .height(Size::Fill(1.0));
2899 let mut root = column([big, small]).height(Size::Fixed(300.0));
2900 let mut state = UiState::new();
2901 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2902 let big_h = state.rect(&root.children[0].computed_id).h;
2904 let small_h = state.rect(&root.children[1].computed_id).h;
2905 assert!(
2906 (big_h - 200.0).abs() < 0.5,
2907 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2908 );
2909 assert!(
2910 (small_h - 100.0).abs() < 0.5,
2911 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2912 );
2913 }
2914
2915 #[test]
2919 fn padding_on_hug_includes_in_intrinsic() {
2920 let root = column([crate::widgets::text::text("x")
2921 .width(Size::Fixed(40.0))
2922 .height(Size::Fixed(40.0))])
2923 .padding(Sides::all(20.0));
2924 let (w, h) = intrinsic(&root);
2925 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2927 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2928 }
2929
2930 #[test]
2934 fn align_end_pins_to_cross_axis_far_edge() {
2935 let mut root = crate::row([crate::widgets::text::text("hi")
2936 .width(Size::Fixed(40.0))
2937 .height(Size::Fixed(20.0))])
2938 .align(Align::End)
2939 .width(Size::Fixed(200.0))
2940 .height(Size::Fixed(100.0));
2941 let mut state = UiState::new();
2942 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2943 let child_rect = state.rect(&root.children[0].computed_id);
2944 assert!(
2946 (child_rect.y - 80.0).abs() < 0.5,
2947 "expected y≈80 (pinned to bottom), got {}",
2948 child_rect.y
2949 );
2950 }
2951
2952 #[test]
2953 fn overlay_can_center_hug_child() {
2954 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2955 .width(Size::Fixed(200.0))
2956 .height(Size::Hug)])
2957 .align(Align::Center)
2958 .justify(Justify::Center);
2959 let mut state = UiState::new();
2960 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2961 let child_rect = state.rect(&root.children[0].computed_id);
2962 assert!(
2963 (child_rect.x - 200.0).abs() < 0.5,
2964 "expected x≈200, got {}",
2965 child_rect.x
2966 );
2967 assert!(
2968 child_rect.y > 100.0 && child_rect.y < 200.0,
2969 "expected centered y, got {}",
2970 child_rect.y
2971 );
2972 }
2973
2974 #[test]
2975 fn scroll_offset_translates_children_and_clamps_to_content() {
2976 let mut root = scroll(
2980 (0..6)
2981 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2982 )
2983 .key("list")
2984 .gap(12.0)
2985 .height(Size::Fixed(200.0));
2986 let mut state = UiState::new();
2987 assign_ids(&mut root);
2988 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2989
2990 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2991
2992 let stored = state
2994 .scroll
2995 .offsets
2996 .get(&root.computed_id)
2997 .copied()
2998 .unwrap_or(0.0);
2999 assert!(
3000 (stored - 80.0).abs() < 0.01,
3001 "offset clamped unexpectedly: {stored}"
3002 );
3003 let c0 = state.rect(&root.children[0].computed_id);
3005 assert!(
3006 (c0.y - (-80.0)).abs() < 0.01,
3007 "child 0 y = {} (expected -80)",
3008 c0.y
3009 );
3010 state
3012 .scroll
3013 .offsets
3014 .insert(root.computed_id.clone(), 9999.0);
3015 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3016 let stored = state
3017 .scroll
3018 .offsets
3019 .get(&root.computed_id)
3020 .copied()
3021 .unwrap_or(0.0);
3022 assert!(
3023 (stored - 160.0).abs() < 0.01,
3024 "overshoot clamped to {stored}"
3025 );
3026 let mut tiny =
3028 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
3029 .height(Size::Fixed(200.0));
3030 let mut tiny_state = UiState::new();
3031 assign_ids(&mut tiny);
3032 tiny_state
3033 .scroll
3034 .offsets
3035 .insert(tiny.computed_id.clone(), 50.0);
3036 layout(
3037 &mut tiny,
3038 &mut tiny_state,
3039 Rect::new(0.0, 0.0, 300.0, 200.0),
3040 );
3041 assert_eq!(
3042 tiny_state
3043 .scroll
3044 .offsets
3045 .get(&tiny.computed_id)
3046 .copied()
3047 .unwrap_or(0.0),
3048 0.0
3049 );
3050 }
3051
3052 #[test]
3053 fn scroll_layout_prunes_far_offscreen_descendants() {
3054 let far = column([crate::widgets::text::text("far row body").key("far-text")])
3055 .height(Size::Fixed(40.0));
3056 let mut root = scroll([
3057 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
3058 crate::tree::spacer().height(Size::Fixed(400.0)),
3059 far,
3060 ])
3061 .key("list")
3062 .height(Size::Fixed(80.0));
3063 let mut state = UiState::new();
3064 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
3065 let stats = take_prune_stats();
3066
3067 assert!(
3068 stats.subtrees >= 1,
3069 "expected at least one far scroll child to be pruned, got {stats:?}"
3070 );
3071 assert!(
3072 stats.nodes >= 1,
3073 "expected pruned descendants to be zeroed, got {stats:?}"
3074 );
3075 let far_text = state
3076 .rect_of_key("far-text")
3077 .expect("far text keeps a zero rect while pruned");
3078 assert_eq!(far_text.w, 0.0);
3079 assert_eq!(far_text.h, 0.0);
3080 }
3081
3082 #[test]
3083 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
3084 let make_root = || {
3085 let paragraph_text = "Variable width text wraps into a different number of lines when \
3086 the viewport narrows, which used to make a plain scroll box lose \
3087 the item the user was reading.";
3088 scroll([column((0..30).map(|i| {
3089 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
3090 .key(format!("paragraph-{i}"))
3091 }))
3092 .gap(8.0)])
3093 .key("article")
3094 .height(Size::Fixed(180.0))
3095 };
3096
3097 let mut root = make_root();
3098 let mut state = UiState::new();
3099 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3100
3101 state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
3102 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3103
3104 let anchor = state
3105 .scroll
3106 .scroll_anchors
3107 .get(&root.computed_id)
3108 .cloned()
3109 .expect("plain scroll should store a visible descendant anchor");
3110 let before_rect = state.rect(&anchor.node_id);
3111 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
3112 let before_offset = state.scroll_offset(&root.computed_id);
3113
3114 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
3115
3116 let after_rect = state.rect(&anchor.node_id);
3117 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
3118 let after_offset = state.scroll_offset(&root.computed_id);
3119 assert!(
3120 (after_anchor_y - before_anchor_y).abs() < 0.5,
3121 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
3122 );
3123 assert!(
3124 (after_offset - before_offset).abs() > 20.0,
3125 "offset should absorb height changes above the anchor"
3126 );
3127 }
3128
3129 #[test]
3130 fn scrollbar_thumb_size_and_position_track_overflow() {
3131 let mut root = scroll(
3134 (0..6)
3135 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3136 )
3137 .gap(12.0)
3138 .height(Size::Fixed(200.0));
3139 let mut state = UiState::new();
3140 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3141
3142 let metrics = state
3143 .scroll
3144 .metrics
3145 .get(&root.computed_id)
3146 .copied()
3147 .expect("scrollable should have metrics");
3148 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
3149 assert!((metrics.content_h - 360.0).abs() < 0.01);
3150 assert!((metrics.max_offset - 160.0).abs() < 0.01);
3151
3152 let thumb = state
3153 .scroll
3154 .thumb_rects
3155 .get(&root.computed_id)
3156 .copied()
3157 .expect("scrollable with scrollbar() and overflow gets a thumb");
3158 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
3160 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
3161 assert!(thumb.y.abs() < 0.01);
3163 assert!(
3165 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
3166 "thumb anchored at {} (expected {})",
3167 thumb.x,
3168 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
3169 );
3170
3171 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
3173 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3174 let thumb = state
3175 .scroll
3176 .thumb_rects
3177 .get(&root.computed_id)
3178 .copied()
3179 .unwrap();
3180 let track_remaining = 200.0 - thumb.h;
3181 let expected_y = track_remaining * (80.0 / 160.0);
3182 assert!(
3183 (thumb.y - expected_y).abs() < 0.5,
3184 "thumb at half-scroll y = {} (expected {expected_y})",
3185 thumb.y,
3186 );
3187 }
3188
3189 #[test]
3190 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
3191 let mut root = scroll(
3195 (0..6)
3196 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3197 )
3198 .gap(12.0)
3199 .height(Size::Fixed(200.0));
3200 let mut state = UiState::new();
3201 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3202
3203 let thumb = state
3204 .scroll
3205 .thumb_rects
3206 .get(&root.computed_id)
3207 .copied()
3208 .unwrap();
3209 let track = state
3210 .scroll
3211 .thumb_tracks
3212 .get(&root.computed_id)
3213 .copied()
3214 .unwrap();
3215 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
3217 assert!(
3218 (track.right() - thumb.right()).abs() < 0.01,
3219 "track and thumb must share the right edge",
3220 );
3221 assert!(
3224 (track.h - 200.0).abs() < 0.01,
3225 "track height = {} (expected 200)",
3226 track.h,
3227 );
3228 }
3229
3230 #[test]
3231 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
3232 let mut suppressed = scroll(
3234 (0..6)
3235 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3236 )
3237 .no_scrollbar()
3238 .height(Size::Fixed(200.0));
3239 let mut state = UiState::new();
3240 layout(
3241 &mut suppressed,
3242 &mut state,
3243 Rect::new(0.0, 0.0, 300.0, 200.0),
3244 );
3245 assert!(
3246 !state
3247 .scroll
3248 .thumb_rects
3249 .contains_key(&suppressed.computed_id)
3250 );
3251
3252 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
3254 .height(Size::Fixed(200.0));
3255 let mut tiny_state = UiState::new();
3256 layout(
3257 &mut tiny,
3258 &mut tiny_state,
3259 Rect::new(0.0, 0.0, 300.0, 200.0),
3260 );
3261 assert!(
3262 !tiny_state
3263 .scroll
3264 .thumb_rects
3265 .contains_key(&tiny.computed_id)
3266 );
3267 }
3268
3269 #[test]
3270 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
3271 let make_root = || {
3272 scroll([
3273 crate::tree::spacer().height(Size::Fixed(80.0)),
3274 scroll((0..6).map(|i| {
3275 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
3276 }))
3277 .key("inner")
3278 .height(Size::Fixed(120.0)),
3279 crate::tree::spacer().height(Size::Fixed(260.0)),
3280 ])
3281 .key("outer")
3282 .height(Size::Fixed(220.0))
3283 };
3284
3285 let mut root = make_root();
3286 let mut state = UiState::new();
3287 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3288 let inner = root
3289 .children
3290 .iter()
3291 .find(|child| child.key.as_deref() == Some("inner"))
3292 .expect("inner scroll");
3293 let inner_id = inner.computed_id.clone();
3294 let inner_rect = state.rect(&inner_id);
3295 let thumb = state
3296 .scroll
3297 .thumb_rects
3298 .get(&inner_id)
3299 .copied()
3300 .expect("inner scroll should have a thumb");
3301 let track = state
3302 .scroll
3303 .thumb_tracks
3304 .get(&inner_id)
3305 .copied()
3306 .expect("inner scroll should have a track");
3307 let thumb_rel_y = thumb.y - inner_rect.y;
3308 let track_rel_y = track.y - inner_rect.y;
3309
3310 state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3311 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3312 let inner_rect_after = state.rect(&inner_id);
3313 let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3314 let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3315
3316 assert!(
3317 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3318 "outer scroll should shift the inner viewport"
3319 );
3320 assert!(
3321 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3322 "inner thumb should stay fixed relative to its viewport"
3323 );
3324 assert!(
3325 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3326 "inner track should stay fixed relative to its viewport"
3327 );
3328 }
3329
3330 #[test]
3331 fn layout_override_places_children_at_returned_rects() {
3332 let mut root = column((0..3).map(|i| {
3334 crate::widgets::text::text(format!("dot {i}"))
3335 .width(Size::Fixed(20.0))
3336 .height(Size::Fixed(20.0))
3337 }))
3338 .width(Size::Fixed(200.0))
3339 .height(Size::Fixed(200.0))
3340 .layout(|ctx| {
3341 ctx.children
3342 .iter()
3343 .enumerate()
3344 .map(|(i, _)| {
3345 let off = i as f32 * 30.0;
3346 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3347 })
3348 .collect()
3349 });
3350 let mut state = UiState::new();
3351 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3352 let r0 = state.rect(&root.children[0].computed_id);
3353 let r1 = state.rect(&root.children[1].computed_id);
3354 let r2 = state.rect(&root.children[2].computed_id);
3355 assert_eq!((r0.x, r0.y), (0.0, 0.0));
3356 assert_eq!((r1.x, r1.y), (30.0, 30.0));
3357 assert_eq!((r2.x, r2.y), (60.0, 60.0));
3358 }
3359
3360 #[test]
3361 fn layout_override_rect_of_key_resolves_earlier_sibling() {
3362 use crate::tree::stack;
3368 let trigger_x = 40.0;
3369 let trigger_y = 20.0;
3370 let trigger_w = 60.0;
3371 let trigger_h = 30.0;
3372 let mut root = stack([
3373 crate::widgets::button::button("Open")
3375 .key("trig")
3376 .width(Size::Fixed(trigger_w))
3377 .height(Size::Fixed(trigger_h)),
3378 stack([crate::widgets::text::text("popover")
3381 .width(Size::Fixed(80.0))
3382 .height(Size::Fixed(20.0))])
3383 .width(Size::Fill(1.0))
3384 .height(Size::Fill(1.0))
3385 .layout(|ctx| {
3386 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3387 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3388 }),
3389 ])
3390 .padding(Sides::xy(trigger_x, trigger_y));
3391 let mut state = UiState::new();
3392 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3393
3394 let popover_layer = &root.children[1];
3395 let panel_id = &popover_layer.children[0].computed_id;
3396 let panel_rect = state.rect(panel_id);
3397 assert!(
3400 (panel_rect.x - trigger_x).abs() < 0.01,
3401 "popover x = {} (expected {trigger_x})",
3402 panel_rect.x,
3403 );
3404 assert!(
3405 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3406 "popover y = {} (expected {})",
3407 panel_rect.y,
3408 trigger_y + trigger_h + 4.0,
3409 );
3410 }
3411
3412 #[test]
3413 fn layout_override_rect_of_key_returns_none_for_missing_key() {
3414 let mut root = column([crate::widgets::text::text("inner")
3415 .width(Size::Fixed(40.0))
3416 .height(Size::Fixed(20.0))])
3417 .width(Size::Fixed(200.0))
3418 .height(Size::Fixed(200.0))
3419 .layout(|ctx| {
3420 assert!((ctx.rect_of_key)("nope").is_none());
3421 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3422 });
3423 let mut state = UiState::new();
3424 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3425 }
3426
3427 #[test]
3428 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3429 use crate::tree::stack;
3435 let mut root = stack([
3436 stack([crate::widgets::text::text("panel")
3437 .width(Size::Fixed(40.0))
3438 .height(Size::Fixed(20.0))])
3439 .width(Size::Fill(1.0))
3440 .height(Size::Fill(1.0))
3441 .layout(|ctx| {
3442 assert!(
3443 (ctx.rect_of_key)("later").is_none(),
3444 "later sibling's rect must not be available yet"
3445 );
3446 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3447 }),
3448 crate::widgets::button::button("after").key("later"),
3449 ]);
3450 let mut state = UiState::new();
3451 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3452 }
3453
3454 #[test]
3455 fn layout_override_measure_returns_intrinsic() {
3456 let mut root = column([crate::widgets::text::text("hi")
3458 .width(Size::Fixed(40.0))
3459 .height(Size::Fixed(20.0))])
3460 .width(Size::Fixed(200.0))
3461 .height(Size::Fixed(200.0))
3462 .layout(|ctx| {
3463 let (w, h) = (ctx.measure)(&ctx.children[0]);
3464 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3465 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3466 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3467 });
3468 let mut state = UiState::new();
3469 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3470 let r = state.rect(&root.children[0].computed_id);
3471 assert_eq!((r.w, r.h), (40.0, 20.0));
3472 }
3473
3474 #[test]
3475 #[should_panic(expected = "returned 1 rects for 2 children")]
3476 fn layout_override_length_mismatch_panics() {
3477 let mut root = column([
3478 crate::widgets::text::text("a")
3479 .width(Size::Fixed(10.0))
3480 .height(Size::Fixed(10.0)),
3481 crate::widgets::text::text("b")
3482 .width(Size::Fixed(10.0))
3483 .height(Size::Fixed(10.0)),
3484 ])
3485 .width(Size::Fixed(200.0))
3486 .height(Size::Fixed(200.0))
3487 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3488 let mut state = UiState::new();
3489 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3490 }
3491
3492 #[test]
3493 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3494 fn layout_override_hug_panics() {
3495 let mut root = column([column([crate::widgets::text::text("c")])
3499 .width(Size::Hug)
3500 .height(Size::Fixed(200.0))
3501 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3502 .width(Size::Fixed(200.0))
3503 .height(Size::Fixed(200.0));
3504 let mut state = UiState::new();
3505 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3506 }
3507
3508 #[test]
3509 fn virtual_list_realizes_only_visible_rows() {
3510 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3514 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3515 });
3516 let mut state = UiState::new();
3517 assign_ids(&mut root);
3518 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3519 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3520
3521 assert_eq!(
3522 root.children.len(),
3523 5,
3524 "expected 5 realized rows, got {}",
3525 root.children.len()
3526 );
3527 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3529 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3530 let r0 = state.rect(&root.children[0].computed_id);
3532 assert!(
3533 (r0.y - (-20.0)).abs() < 0.5,
3534 "row 2 expected y≈-20, got {}",
3535 r0.y
3536 );
3537 }
3538
3539 #[test]
3540 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
3541 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
3542 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3543 })
3544 .gap(10.0);
3545 let mut state = UiState::new();
3546 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3547
3548 assert_eq!(
3549 root.children.len(),
3550 3,
3551 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3552 );
3553 let row_1 = root
3554 .children
3555 .iter()
3556 .find(|c| c.key.as_deref() == Some("row-1"))
3557 .expect("row 1 should be realized");
3558 assert!(
3559 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3560 "gap should place row 1 at y=50"
3561 );
3562 let metrics = state
3563 .scroll
3564 .metrics
3565 .get(&root.computed_id)
3566 .expect("virtual list writes scroll metrics");
3567 assert!(
3568 (metrics.content_h - 490.0).abs() < 0.5,
3569 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3570 metrics.content_h
3571 );
3572 }
3573
3574 #[test]
3575 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3576 let make_root = || {
3577 crate::tree::virtual_list(50, 50.0, |i| {
3578 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3579 })
3580 };
3581
3582 let mut state = UiState::new();
3583 let mut root_a = make_root();
3584 assign_ids(&mut root_a);
3585 state
3587 .scroll
3588 .offsets
3589 .insert(root_a.computed_id.clone(), 250.0);
3590 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3591 let id_at_offset_a = root_a
3592 .children
3593 .iter()
3594 .find(|c| c.key.as_deref() == Some("row-5"))
3595 .unwrap()
3596 .computed_id
3597 .clone();
3598
3599 let mut root_b = make_root();
3601 assign_ids(&mut root_b);
3602 state
3603 .scroll
3604 .offsets
3605 .insert(root_b.computed_id.clone(), 200.0);
3606 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3607 let id_at_offset_b = root_b
3608 .children
3609 .iter()
3610 .find(|c| c.key.as_deref() == Some("row-5"))
3611 .unwrap()
3612 .computed_id
3613 .clone();
3614
3615 assert_eq!(
3616 id_at_offset_a, id_at_offset_b,
3617 "row-5's computed_id changed when scroll offset moved"
3618 );
3619 }
3620
3621 #[test]
3622 fn virtual_list_clamps_overshoot_offset() {
3623 let mut root =
3625 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3626 let mut state = UiState::new();
3627 assign_ids(&mut root);
3628 state
3629 .scroll
3630 .offsets
3631 .insert(root.computed_id.clone(), 9999.0);
3632 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3633 let stored = state
3634 .scroll
3635 .offsets
3636 .get(&root.computed_id)
3637 .copied()
3638 .unwrap_or(0.0);
3639 assert!(
3640 (stored - 300.0).abs() < 0.01,
3641 "expected clamp to 300, got {stored}"
3642 );
3643 }
3644
3645 #[test]
3646 fn virtual_list_empty_count_realizes_no_children() {
3647 let mut root =
3648 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3649 let mut state = UiState::new();
3650 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3651 assert_eq!(root.children.len(), 0);
3652 }
3653
3654 #[test]
3655 #[should_panic(expected = "row_height > 0.0")]
3656 fn virtual_list_zero_row_height_panics() {
3657 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3658 }
3659
3660 #[test]
3661 #[should_panic(expected = "Size::Hug would defeat virtualization")]
3662 fn virtual_list_hug_panics() {
3663 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3664 crate::widgets::text::text(format!("r{i}"))
3665 })
3666 .height(Size::Hug)])
3667 .width(Size::Fixed(300.0))
3668 .height(Size::Fixed(200.0));
3669 let mut state = UiState::new();
3670 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3671 }
3672
3673 #[test]
3674 fn virtual_list_dyn_respects_per_row_fixed_heights() {
3675 let mut root = crate::tree::virtual_list_dyn(
3679 20,
3680 50.0,
3681 |i| format!("row-{i}"),
3682 |i| {
3683 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3684 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3685 .key(format!("row-{i}"))
3686 .height(Size::Fixed(h))
3687 },
3688 );
3689 let mut state = UiState::new();
3690 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3691
3692 assert_eq!(
3693 root.children.len(),
3694 4,
3695 "expected 4 realized rows, got {}",
3696 root.children.len()
3697 );
3698 let ys: Vec<f32> = root
3700 .children
3701 .iter()
3702 .map(|c| state.rect(&c.computed_id).y)
3703 .collect();
3704 assert!(
3705 (ys[0] - 0.0).abs() < 0.5,
3706 "row 0 expected y≈0, got {}",
3707 ys[0]
3708 );
3709 assert!(
3710 (ys[1] - 40.0).abs() < 0.5,
3711 "row 1 expected y≈40, got {}",
3712 ys[1]
3713 );
3714 assert!(
3715 (ys[2] - 120.0).abs() < 0.5,
3716 "row 2 expected y≈120, got {}",
3717 ys[2]
3718 );
3719 assert!(
3720 (ys[3] - 160.0).abs() < 0.5,
3721 "row 3 expected y≈160, got {}",
3722 ys[3]
3723 );
3724 }
3725
3726 #[test]
3727 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3728 let mut root = crate::tree::virtual_list_dyn(
3729 10,
3730 40.0,
3731 |i| format!("row-{i}"),
3732 |i| {
3733 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3734 .key(format!("row-{i}"))
3735 .height(Size::Fixed(40.0))
3736 },
3737 )
3738 .gap(10.0);
3739 let mut state = UiState::new();
3740 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3741
3742 assert_eq!(
3743 root.children.len(),
3744 3,
3745 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3746 );
3747 let row_1 = root
3748 .children
3749 .iter()
3750 .find(|c| c.key.as_deref() == Some("row-1"))
3751 .expect("row 1 should be realized");
3752 assert!(
3753 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3754 "gap should place row 1 at y=50"
3755 );
3756 let metrics = state
3757 .scroll
3758 .metrics
3759 .get(&root.computed_id)
3760 .expect("virtual list writes scroll metrics");
3761 assert!(
3762 (metrics.content_h - 490.0).abs() < 0.5,
3763 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3764 metrics.content_h
3765 );
3766 }
3767
3768 #[test]
3769 fn virtual_list_dyn_caches_measured_heights() {
3770 let mut root = crate::tree::virtual_list_dyn(
3774 50,
3775 50.0,
3776 |i| format!("row-{i}"),
3777 |i| {
3778 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3779 .key(format!("row-{i}"))
3780 .height(Size::Fixed(30.0))
3781 },
3782 );
3783 let mut state = UiState::new();
3784 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3785
3786 let measured = state
3787 .scroll
3788 .measured_row_heights
3789 .get(&root.computed_id)
3790 .expect("dynamic virtual list should populate the height cache");
3791 assert!(
3795 measured.len() >= 6,
3796 "expected ≥ 6 cached row heights, got {}",
3797 measured.len()
3798 );
3799 for by_width in measured.values() {
3800 let h = by_width
3801 .get(&300)
3802 .copied()
3803 .expect("measurement should be keyed at the 300px width bucket");
3804 assert!(
3805 (h - 30.0).abs() < 0.5,
3806 "expected cached height ≈ 30, got {h}"
3807 );
3808 }
3809 }
3810
3811 #[test]
3812 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3813 let make_root = || {
3814 crate::tree::virtual_list_dyn(
3815 100,
3816 40.0,
3817 |i| format!("row-{i}"),
3818 |i| {
3819 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3820 .key(format!("row-{i}"))
3821 .height(Size::Fixed(40.0))
3822 },
3823 )
3824 };
3825 let mut root = make_root();
3826 let mut state = UiState::new();
3827 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3828
3829 state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3830 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3831
3832 let anchor = state
3833 .scroll
3834 .virtual_anchors
3835 .get(&root.computed_id)
3836 .cloned()
3837 .expect("dynamic list should store a visible anchor");
3838 let before_y = root
3839 .children
3840 .iter()
3841 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3842 .map(|child| state.rect(&child.computed_id).y)
3843 .expect("anchor row should be realized");
3844 let before_offset = state.scroll_offset(&root.computed_id);
3845
3846 state
3847 .scroll
3848 .measured_row_heights
3849 .entry(root.computed_id.clone())
3850 .or_default()
3851 .entry("row-0".to_string())
3852 .or_default()
3853 .insert(300, 120.0);
3854
3855 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3856 let after_y = root
3857 .children
3858 .iter()
3859 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3860 .map(|child| state.rect(&child.computed_id).y)
3861 .expect("anchor row should remain realized");
3862 let after_offset = state.scroll_offset(&root.computed_id);
3863
3864 assert!(
3865 (after_y - before_y).abs() < 0.5,
3866 "anchor row should stay at y={before_y}, got {after_y}"
3867 );
3868 assert!(
3869 (after_offset - (before_offset + 80.0)).abs() < 0.5,
3870 "offset should absorb the 80px measurement delta above anchor"
3871 );
3872 }
3873
3874 #[test]
3875 fn virtual_list_dyn_height_cache_is_width_bucketed() {
3876 let mut root = crate::tree::virtual_list_dyn(
3877 20,
3878 50.0,
3879 |i| format!("row-{i}"),
3880 |i| {
3881 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3882 .key(format!("row-{i}"))
3883 .height(Size::Fixed(30.0))
3884 },
3885 );
3886 let mut state = UiState::new();
3887 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3888 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3889
3890 let row_0 = state
3891 .scroll
3892 .measured_row_heights
3893 .get(&root.computed_id)
3894 .and_then(|m| m.get("row-0"))
3895 .expect("row 0 should be measured");
3896 assert!(
3897 row_0.contains_key(&300) && row_0.contains_key(&240),
3898 "expected width buckets 300 and 240, got {:?}",
3899 row_0.keys().collect::<Vec<_>>()
3900 );
3901 }
3902
3903 #[test]
3904 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3905 let make_root = || {
3910 crate::tree::virtual_list_dyn(
3911 20,
3912 50.0,
3913 |i| format!("row-{i}"),
3914 |i| {
3915 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3916 .key(format!("row-{i}"))
3917 .height(Size::Fixed(30.0))
3918 },
3919 )
3920 };
3921 let mut state = UiState::new();
3922 let mut root = make_root();
3923 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3924
3925 state
3926 .scroll
3927 .offsets
3928 .insert(root.computed_id.clone(), 9999.0);
3929 let mut root2 = make_root();
3930 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3931
3932 let measured = state
3933 .scroll
3934 .measured_row_heights
3935 .get(&root2.computed_id)
3936 .expect("dynamic virtual list should populate the height cache");
3937 let measured_sum = measured
3938 .values()
3939 .filter_map(|by_width| by_width.get(&300))
3940 .sum::<f32>();
3941 let measured_count = measured
3942 .values()
3943 .filter(|by_width| by_width.contains_key(&300))
3944 .count();
3945 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3946 let expected_max_offset = expected_total - 200.0;
3947
3948 let stored = state
3949 .scroll
3950 .offsets
3951 .get(&root2.computed_id)
3952 .copied()
3953 .unwrap_or(0.0);
3954 assert!(
3955 (stored - expected_max_offset).abs() < 0.5,
3956 "expected offset clamped to {expected_max_offset}, got {stored}"
3957 );
3958 }
3959
3960 #[test]
3961 fn virtual_list_dyn_empty_count_realizes_no_children() {
3962 let mut root = crate::tree::virtual_list_dyn(
3963 0,
3964 50.0,
3965 |i| format!("row-{i}"),
3966 |i| crate::widgets::text::text(format!("r{i}")),
3967 );
3968 let mut state = UiState::new();
3969 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3970 assert_eq!(root.children.len(), 0);
3971 }
3972
3973 #[test]
3974 #[should_panic(expected = "estimated_row_height > 0.0")]
3975 fn virtual_list_dyn_zero_estimate_panics() {
3976 let _ = crate::tree::virtual_list_dyn(
3977 10,
3978 0.0,
3979 |i| format!("row-{i}"),
3980 |i| crate::widgets::text::text(format!("r{i}")),
3981 );
3982 }
3983
3984 #[test]
3985 fn text_runs_constructor_shape_smoke() {
3986 let el = crate::tree::text_runs([
3987 crate::widgets::text::text("Hello, "),
3988 crate::widgets::text::text("world").bold(),
3989 crate::tree::hard_break(),
3990 crate::widgets::text::text("of text").italic(),
3991 ]);
3992 assert_eq!(el.kind, Kind::Inlines);
3993 assert_eq!(el.children.len(), 4);
3994 assert!(matches!(
3995 el.children[1].font_weight,
3996 FontWeight::Bold | FontWeight::Semibold
3997 ));
3998 assert_eq!(el.children[2].kind, Kind::HardBreak);
3999 assert!(el.children[3].text_italic);
4000 }
4001
4002 #[test]
4003 fn wrapped_text_hugs_multiline_height_from_available_width() {
4004 let mut root = column([crate::paragraph(
4005 "A longer sentence should wrap into multiple measured lines.",
4006 )])
4007 .width(Size::Fill(1.0))
4008 .height(Size::Hug);
4009
4010 let mut state = UiState::new();
4011 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
4012
4013 let child_rect = state.rect(&root.children[0].computed_id);
4014 assert_eq!(child_rect.w, 180.0);
4015 assert!(
4016 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4017 "expected multiline paragraph height, got {}",
4018 child_rect.h
4019 );
4020 }
4021
4022 #[test]
4023 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
4024 const PANEL_W: f32 = 240.0;
4035 const PADDING: f32 = 18.0;
4036 const GAP: f32 = 12.0;
4037
4038 let panel = column([
4039 crate::paragraph(
4040 "A long enough warning paragraph that it has to wrap onto a second line \
4041 inside this narrow panel.",
4042 ),
4043 crate::widgets::button::button("OK").key("ok"),
4044 ])
4045 .width(Size::Fixed(PANEL_W))
4046 .height(Size::Hug)
4047 .padding(Sides::all(PADDING))
4048 .gap(GAP)
4049 .align(Align::Stretch);
4050
4051 let mut root = crate::stack([panel])
4052 .width(Size::Fill(1.0))
4053 .height(Size::Fill(1.0));
4054 let mut state = UiState::new();
4055 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4056
4057 let panel_rect = state.rect(&root.children[0].computed_id);
4058 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
4059
4060 let para_rect = state.rect(&root.children[0].children[0].computed_id);
4061 let button_rect = state.rect(&root.children[0].children[1].computed_id);
4062
4063 assert!(
4066 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4067 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
4068 got h={}",
4069 para_rect.h
4070 );
4071
4072 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
4078 assert!(
4079 (bottom_padding - PADDING).abs() < 0.5,
4080 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
4081 );
4082 }
4083
4084 #[test]
4085 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
4086 const COL_W: f32 = 600.0;
4098 const GUTTER_W: f32 = 3.0;
4099
4100 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
4101 sed do eiusmod tempor incididunt ut labore et dolore magna \
4102 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
4103 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
4104
4105 let make_row = || {
4106 let gutter = El::new(Kind::Custom("gutter"))
4107 .width(Size::Fixed(GUTTER_W))
4108 .height(Size::Fill(1.0));
4109 let body = crate::paragraph(long).width(Size::Fill(1.0));
4110 crate::row([gutter, body]).width(Size::Fill(1.0))
4111 };
4112
4113 let mut root = column([make_row(), make_row()])
4114 .width(Size::Fixed(COL_W))
4115 .height(Size::Hug)
4116 .align(Align::Stretch);
4117 let mut state = UiState::new();
4118 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
4119
4120 let row0_rect = state.rect(&root.children[0].computed_id);
4121 let row1_rect = state.rect(&root.children[1].computed_id);
4122 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
4123
4124 let line_height = crate::tokens::TEXT_SM.line_height;
4129 assert!(
4130 para0_rect.h > line_height * 1.5,
4131 "paragraph should wrap to multiple lines at ~597px wide; \
4132 got h={} (line_height={})",
4133 para0_rect.h,
4134 line_height,
4135 );
4136 assert!(
4137 row0_rect.h > line_height * 1.5,
4138 "row 0 should accommodate the wrapped paragraph height; \
4139 got h={} (line_height={})",
4140 row0_rect.h,
4141 line_height,
4142 );
4143
4144 assert!(
4146 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
4147 "row 1 starts at y={} but row 0 occupies y={}..{}",
4148 row1_rect.y,
4149 row0_rect.y,
4150 row0_rect.y + row0_rect.h,
4151 );
4152 }
4153
4154 #[test]
4159 fn min_width_floors_resolved_cross_axis_size() {
4160 let mut root = column([crate::widgets::text::text("hi")
4161 .width(Size::Fixed(40.0))
4162 .height(Size::Fixed(20.0))
4163 .min_width(120.0)])
4164 .align(Align::Start)
4165 .width(Size::Fixed(500.0))
4166 .height(Size::Fixed(200.0));
4167 let mut state = UiState::new();
4168 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4169 let child_rect = state.rect(&root.children[0].computed_id);
4170 assert!(
4171 (child_rect.w - 120.0).abs() < 0.5,
4172 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
4173 child_rect.w,
4174 );
4175 }
4176
4177 #[test]
4180 fn max_width_caps_fill_child() {
4181 let mut root = crate::row([crate::widgets::text::text("body")
4182 .width(Size::Fill(1.0))
4183 .height(Size::Fixed(20.0))
4184 .max_width(160.0)])
4185 .width(Size::Fixed(800.0))
4186 .height(Size::Fixed(40.0));
4187 let mut state = UiState::new();
4188 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
4189 let child_rect = state.rect(&root.children[0].computed_id);
4190 assert!(
4191 (child_rect.w - 160.0).abs() < 0.5,
4192 "expected Fill child capped at 160, got w={}",
4193 child_rect.w,
4194 );
4195 }
4196
4197 #[test]
4200 fn min_width_wins_over_max_width_when_conflicting() {
4201 let mut root = column([crate::widgets::text::text("x")
4202 .width(Size::Fixed(50.0))
4203 .height(Size::Fixed(20.0))
4204 .max_width(80.0)
4205 .min_width(120.0)]);
4206 let mut state = UiState::new();
4207 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4208 let child_rect = state.rect(&root.children[0].computed_id);
4209 assert!(
4210 (child_rect.w - 120.0).abs() < 0.5,
4211 "expected min_width (120) to win over max_width (80), got w={}",
4212 child_rect.w,
4213 );
4214 }
4215
4216 #[test]
4220 fn min_height_floors_hug_column_inside_fixed_parent() {
4221 let inner = column([crate::widgets::text::text("a")
4222 .width(Size::Fixed(40.0))
4223 .height(Size::Fixed(20.0))])
4224 .width(Size::Fixed(80.0))
4225 .height(Size::Hug)
4226 .min_height(200.0);
4227 let mut root = column([inner])
4228 .align(Align::Start)
4229 .width(Size::Fixed(800.0))
4230 .height(Size::Fixed(600.0));
4231 let mut state = UiState::new();
4232 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4233 let inner_rect = state.rect(&root.children[0].computed_id);
4234 assert!(
4235 (inner_rect.h - 200.0).abs() < 0.5,
4236 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
4237 inner_rect.h,
4238 );
4239 }
4240
4241 #[test]
4250 fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
4251 let mut root = crate::row([
4255 column([crate::widgets::text::paragraph(
4256 "A long enough description that must wrap to two lines at 148px",
4257 )])
4258 .width(Size::Fill(1.0)),
4259 crate::widgets::text::text("ok")
4260 .width(Size::Fixed(40.0))
4261 .height(Size::Fixed(20.0)),
4262 ])
4263 .gap(12.0)
4264 .align(Align::Center)
4265 .width(Size::Fixed(200.0));
4266 let mut state = UiState::new();
4267 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
4268 let col_rect = state.rect(&root.children[0].computed_id);
4270 let para_rect = state.rect(&root.children[0].children[0].computed_id);
4271 assert!(
4272 (col_rect.h - para_rect.h).abs() < 0.5,
4273 "column height ({}) should track its wrapped child's height ({})",
4274 col_rect.h,
4275 para_rect.h,
4276 );
4277 }
4278
4279 #[test]
4283 fn aspect_on_column_main_axis_derives_from_cross() {
4284 let mut root = column([El::new(Kind::Group)
4285 .width(Size::Fill(1.0))
4286 .height(Size::Aspect(0.5))])
4287 .width(Size::Fixed(200.0))
4288 .height(Size::Fixed(400.0));
4289 let mut state = UiState::new();
4290 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
4291 let r = state.rect(&root.children[0].computed_id);
4292 assert!(
4293 (r.w - 200.0).abs() < 0.5,
4294 "expected w≈200 (Fill), got {}",
4295 r.w,
4296 );
4297 assert!(
4298 (r.h - 100.0).abs() < 0.5,
4299 "expected h≈100 (Aspect 0.5 of 200), got {}",
4300 r.h,
4301 );
4302 }
4303
4304 #[test]
4308 fn aspect_height_pushes_siblings_in_column() {
4309 let mut root = column([
4310 El::new(Kind::Group)
4311 .width(Size::Fill(1.0))
4312 .height(Size::Aspect(0.25)),
4313 crate::widgets::text::text("caption")
4314 .width(Size::Fixed(40.0))
4315 .height(Size::Fixed(20.0)),
4316 ])
4317 .width(Size::Fixed(400.0))
4318 .height(Size::Fixed(500.0));
4319 let mut state = UiState::new();
4320 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
4321 let img = state.rect(&root.children[0].computed_id);
4322 let cap = state.rect(&root.children[1].computed_id);
4323 assert!(
4324 (img.h - 100.0).abs() < 0.5,
4325 "expected aspect-derived height ≈100, got {}",
4326 img.h,
4327 );
4328 assert!(
4329 (cap.y - 100.0).abs() < 0.5,
4330 "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
4331 cap.y,
4332 );
4333 }
4334
4335 #[test]
4339 fn aspect_on_row_cross_axis_derives_from_main() {
4340 let mut root = crate::row([El::new(Kind::Group)
4341 .height(Size::Fill(1.0))
4342 .width(Size::Aspect(2.0))])
4343 .width(Size::Fixed(800.0))
4344 .height(Size::Fixed(200.0));
4345 let mut state = UiState::new();
4346 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
4347 let r = state.rect(&root.children[0].computed_id);
4348 assert!(
4349 (r.h - 200.0).abs() < 0.5,
4350 "expected h≈200 (Fill), got {}",
4351 r.h,
4352 );
4353 assert!(
4354 (r.w - 400.0).abs() < 0.5,
4355 "expected w≈400 (Aspect 2.0 of 200), got {}",
4356 r.w,
4357 );
4358 }
4359
4360 #[test]
4363 fn aspect_on_both_axes_falls_back_to_intrinsic() {
4364 let mut root = column([crate::widgets::text::text("hi")
4365 .width(Size::Aspect(1.0))
4366 .height(Size::Aspect(1.0))])
4367 .width(Size::Fixed(200.0))
4368 .height(Size::Fixed(200.0));
4369 let mut state = UiState::new();
4370 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4371 let r = state.rect(&root.children[0].computed_id);
4372 assert!(
4373 r.w > 0.0 && r.h > 0.0,
4374 "expected finite size for both-Aspect fallback, got {}x{}",
4375 r.w,
4376 r.h,
4377 );
4378 }
4379
4380 #[test]
4384 fn aspect_respects_min_and_max_on_derived_axis() {
4385 let mut root = column([column([El::new(Kind::Group)
4389 .width(Size::Fill(1.0))
4390 .height(Size::Aspect(1.0))
4391 .max_height(120.0)])
4392 .width(Size::Hug)
4393 .height(Size::Hug)])
4394 .width(Size::Fixed(400.0))
4395 .height(Size::Fixed(600.0));
4396 let mut state = UiState::new();
4397 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4398 let panel = state.rect(&root.children[0].computed_id);
4399 let img = state.rect(&root.children[0].children[0].computed_id);
4400 assert!(
4401 (img.h - 120.0).abs() < 0.5,
4402 "max_height should clamp aspect-derived height to 120, got {}",
4403 img.h,
4404 );
4405 assert!(
4406 (panel.h - 120.0).abs() < 0.5,
4407 "hugging panel should match clamped child (120), got {}",
4408 panel.h,
4409 );
4410
4411 let mut root = column([column([El::new(Kind::Group)
4414 .width(Size::Fill(1.0))
4415 .height(Size::Aspect(0.1))
4416 .min_height(200.0)])
4417 .width(Size::Hug)
4418 .height(Size::Hug)])
4419 .width(Size::Fixed(400.0))
4420 .height(Size::Fixed(600.0));
4421 let mut state = UiState::new();
4422 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4423 let panel = state.rect(&root.children[0].computed_id);
4424 let img = state.rect(&root.children[0].children[0].computed_id);
4425 assert!(
4426 (img.h - 200.0).abs() < 0.5,
4427 "min_height should bump aspect-derived height to 200, got {}",
4428 img.h,
4429 );
4430 assert!(
4431 (panel.h - 200.0).abs() < 0.5,
4432 "hugging panel should match bumped child (200), got {}",
4433 panel.h,
4434 );
4435 }
4436
4437 #[test]
4440 fn aspect_basis_is_clamped_before_deriving() {
4441 let mut root = column([El::new(Kind::Group)
4447 .width(Size::Fill(1.0))
4448 .height(Size::Aspect(0.5))
4449 .max_width(100.0)])
4450 .width(Size::Fixed(400.0))
4451 .height(Size::Fixed(400.0));
4452 let mut state = UiState::new();
4453 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4454 let img = state.rect(&root.children[0].computed_id);
4455 assert!(
4456 (img.w - 100.0).abs() < 0.5,
4457 "max_width should cap Fill width at 100, got {}",
4458 img.w,
4459 );
4460 assert!(
4461 (img.h - 50.0).abs() < 0.5,
4462 "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
4463 img.h,
4464 );
4465 }
4466
4467 #[test]
4473 fn hug_column_around_fill_aspect_child_does_not_overflow() {
4474 let mut root = column([column([El::new(Kind::Group)
4481 .width(Size::Fill(1.0))
4482 .height(Size::Aspect(0.5))])
4483 .width(Size::Hug)
4484 .height(Size::Hug)])
4485 .width(Size::Fixed(400.0))
4486 .height(Size::Fixed(400.0));
4487 let mut state = UiState::new();
4488 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4489 let panel = state.rect(&root.children[0].computed_id);
4490 let img = state.rect(&root.children[0].children[0].computed_id);
4491 assert!(
4492 (panel.h - 200.0).abs() < 0.5,
4493 "hugging panel should hug to aspect-derived height 200, got {}",
4494 panel.h,
4495 );
4496 assert!(
4497 (img.h - 200.0).abs() < 0.5,
4498 "image should layout to height 200, got {}",
4499 img.h,
4500 );
4501 assert!(
4502 img.bottom() <= panel.bottom() + 0.5,
4503 "image (bottom={}) must fit within hugging panel (bottom={})",
4504 img.bottom(),
4505 panel.bottom(),
4506 );
4507 }
4508
4509 #[test]
4513 fn hugging_parent_sees_aspect_corrected_intrinsic() {
4514 let mut root = column([column([El::new(Kind::Group)
4518 .width(Size::Fixed(80.0))
4519 .height(Size::Aspect(0.5))])
4520 .width(Size::Hug)
4521 .height(Size::Hug)])
4522 .width(Size::Fixed(400.0))
4523 .height(Size::Fixed(400.0))
4524 .align(Align::Start);
4525 let mut state = UiState::new();
4526 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4527 let hugger = state.rect(&root.children[0].computed_id);
4528 assert!(
4529 (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
4530 "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
4531 hugger.w,
4532 hugger.h,
4533 );
4534 }
4535
4536 #[test]
4538 fn max_height_caps_overlay_child_below_intrinsic() {
4539 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
4542 .width(Size::Fixed(40.0))
4543 .height(Size::Fixed(300.0))])
4544 .width(Size::Hug)
4545 .height(Size::Hug)
4546 .max_height(100.0)])
4547 .width(Size::Fixed(600.0))
4548 .height(Size::Fixed(600.0));
4549 let mut state = UiState::new();
4550 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
4551 let child_rect = state.rect(&root.children[0].computed_id);
4552 assert!(
4553 (child_rect.h - 100.0).abs() < 0.5,
4554 "expected child height capped at 100, got h={}",
4555 child_rect.h,
4556 );
4557 }
4558}