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::Custom(name) => name,
430 }
431}
432
433fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
434 if matches!(node.kind, Kind::Inlines) {
435 for c in &mut node.children {
443 ui_state.layout.computed_rects.insert(
444 c.computed_id.clone(),
445 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
446 );
447 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
451 }
452 return;
453 }
454 if let Some(items) = node.virtual_items.clone() {
455 layout_virtual(node, node_rect, items, ui_state);
456 return;
457 }
458 if let Some(layout_fn) = node.layout_override.clone() {
459 layout_custom(node, node_rect, layout_fn, ui_state);
460 if node.scrollable {
461 apply_scroll_offset(node, node_rect, ui_state);
462 }
463 return;
464 }
465 match node.axis {
466 Axis::Overlay => {
467 let inner = node_rect.inset(node.padding);
468 for c in &mut node.children {
469 let c_rect = overlay_rect(c, inner, node.align, node.justify);
470 ui_state
471 .layout
472 .computed_rects
473 .insert(c.computed_id.clone(), c_rect);
474 layout_children(c, c_rect, ui_state);
475 }
476 }
477 Axis::Column => layout_axis(node, node_rect, true, ui_state),
478 Axis::Row => layout_axis(node, node_rect, false, ui_state),
479 }
480 if node.scrollable {
481 apply_scroll_offset(node, node_rect, ui_state);
482 }
483}
484
485fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
486 let inner = node_rect.inset(node.padding);
487 let measure = |c: &El| intrinsic(c);
488 let key_index = &ui_state.layout.key_index;
493 let computed_rects = &ui_state.layout.computed_rects;
494 let rect_of_key = |key: &str| -> Option<Rect> {
495 let id = key_index.get(key)?;
496 computed_rects.get(id).copied()
497 };
498 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
499 let rects = (layout_fn.0)(LayoutCtx {
500 container: inner,
501 children: &node.children,
502 measure: &measure,
503 rect_of_key: &rect_of_key,
504 rect_of_id: &rect_of_id,
505 });
506 assert_eq!(
507 rects.len(),
508 node.children.len(),
509 "LayoutFn for {:?} returned {} rects for {} children",
510 node.computed_id,
511 rects.len(),
512 node.children.len(),
513 );
514 for (c, c_rect) in node.children.iter_mut().zip(rects) {
515 ui_state
516 .layout
517 .computed_rects
518 .insert(c.computed_id.clone(), c_rect);
519 layout_children(c, c_rect, ui_state);
520 }
521}
522
523fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
529 let inner = node_rect.inset(node.padding);
530 match items.mode {
531 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
532 node,
533 inner,
534 items.count,
535 row_height,
536 items.build_row,
537 ui_state,
538 ),
539 VirtualMode::Dynamic {
540 estimated_row_height,
541 } => layout_virtual_dynamic(
542 node,
543 inner,
544 items.count,
545 estimated_row_height,
546 DynamicVirtualFns {
547 anchor_policy: items.anchor_policy,
548 row_key: items.row_key,
549 build_row: items.build_row,
550 },
551 ui_state,
552 ),
553 }
554}
555
556fn resolve_scroll_requests<F, K>(
566 node: &El,
567 inner: Rect,
568 count: usize,
569 row_extent: F,
570 row_for_key: K,
571 ui_state: &mut UiState,
572) -> bool
573where
574 F: Fn(usize) -> (f32, f32),
575 K: Fn(&str) -> Option<usize>,
576{
577 if ui_state.scroll.pending_requests.is_empty() {
578 return false;
579 }
580 let Some(key) = node.key.as_deref() else {
581 return false;
582 };
583 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
584 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
585 pending.into_iter().partition(|req| match req {
586 ScrollRequest::ToRow { list_key, .. } => list_key == key,
587 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
588 ScrollRequest::EnsureVisible { .. } => false,
591 });
592 ui_state.scroll.pending_requests = remaining;
593
594 let mut wrote = false;
595 for req in matched {
596 let (row, align) = match req {
597 ScrollRequest::ToRow { row, align, .. } => (row, align),
598 ScrollRequest::ToRowKey { row_key, align, .. } => {
599 let Some(row) = row_for_key(&row_key) else {
600 continue;
601 };
602 (row, align)
603 }
604 ScrollRequest::EnsureVisible { .. } => continue,
605 };
606 if row >= count {
607 continue;
608 }
609 let (row_top, row_h) = row_extent(row);
610 let row_bottom = row_top + row_h;
611 let viewport_h = inner.h;
612 let current = ui_state
613 .scroll
614 .offsets
615 .get(&node.computed_id)
616 .copied()
617 .unwrap_or(0.0);
618 let new_offset = match align {
619 ScrollAlignment::Start => row_top,
620 ScrollAlignment::End => row_bottom - viewport_h,
621 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
622 ScrollAlignment::Visible => {
623 if row_top < current {
624 row_top
625 } else if row_bottom > current + viewport_h {
626 row_bottom - viewport_h
627 } else {
628 continue;
629 }
630 }
631 };
632 ui_state
633 .scroll
634 .offsets
635 .insert(node.computed_id.clone(), new_offset);
636 wrote = true;
637 }
638 wrote
639}
640
641fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
644 let max_offset = (total_h - inner.h).max(0.0);
645 let stored = ui_state
646 .scroll
647 .offsets
648 .get(&node.computed_id)
649 .copied()
650 .unwrap_or(0.0);
651 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
652 let offset = stored.clamp(0.0, max_offset);
653 ui_state
654 .scroll
655 .offsets
656 .insert(node.computed_id.clone(), offset);
657 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
658 offset
659}
660
661fn write_virtual_scroll_metrics(
662 node: &El,
663 inner: Rect,
664 total_h: f32,
665 max_offset: f32,
666 offset: f32,
667 ui_state: &mut UiState,
668) {
669 ui_state.scroll.metrics.insert(
670 node.computed_id.clone(),
671 crate::state::ScrollMetrics {
672 viewport_h: inner.h,
673 content_h: total_h,
674 max_offset,
675 },
676 );
677 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
678}
679
680fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
684 let role = role_token(&child.kind);
685 let suffix = match (&child.key, role) {
686 (Some(k), r) => format!("{r}[{k}]"),
687 (None, r) => format!("{r}.{global_i}"),
688 };
689 assign_id(child, &format!("{parent_id}.{suffix}"));
690}
691
692fn layout_virtual_fixed(
693 node: &mut El,
694 inner: Rect,
695 count: usize,
696 row_height: f32,
697 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
698 ui_state: &mut UiState,
699) {
700 let gap = node.gap.max(0.0);
701 let pitch = row_height + gap;
702 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
703 resolve_scroll_requests(
704 node,
705 inner,
706 count,
707 |i| (i as f32 * pitch, row_height),
708 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
709 ui_state,
710 );
711 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
712
713 if count == 0 {
714 node.children.clear();
715 return;
716 }
717
718 let start = (offset / pitch).floor() as usize;
722 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
723
724 let mut realized: Vec<El> = Vec::new();
725 for global_i in start..end {
726 let row_top = global_i as f32 * pitch;
727 if row_top >= offset + inner.h || row_top + row_height <= offset {
728 continue;
729 }
730 let mut child = (build_row)(global_i);
731 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
732
733 let row_y = inner.y + row_top - offset;
734 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
735 ui_state
736 .layout
737 .computed_rects
738 .insert(child.computed_id.clone(), c_rect);
739 layout_children(&mut child, c_rect, ui_state);
740 realized.push(child);
741 }
742 node.children = realized;
743}
744
745fn layout_virtual_dynamic(
746 node: &mut El,
747 inner: Rect,
748 count: usize,
749 estimated_row_height: f32,
750 fns: DynamicVirtualFns,
751 ui_state: &mut UiState,
752) {
753 let gap = node.gap.max(0.0);
754 let width_bucket = virtual_width_bucket(inner.w);
755 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
756 prune_dynamic_measurements(node, &row_keys, ui_state);
757
758 if count == 0 {
759 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
760 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
761 debug_assert_eq!(offset, 0.0);
762 node.children.clear();
763 return;
764 }
765
766 let mut row_heights = dynamic_row_heights(
767 node,
768 &row_keys,
769 width_bucket,
770 estimated_row_height,
771 ui_state,
772 );
773
774 let has_request = node.key.as_deref().is_some_and(|k| {
780 ui_state.scroll.pending_requests.iter().any(|r| match r {
781 ScrollRequest::ToRow { list_key, .. } => list_key == k,
782 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
783 ScrollRequest::EnsureVisible { .. } => false,
784 })
785 });
786 let mut request_wrote = false;
787 if has_request {
788 request_wrote = resolve_scroll_requests(
789 node,
790 inner,
791 count,
792 |target| {
793 (
794 dynamic_row_top(&row_heights, gap, target),
795 row_heights[target],
796 )
797 },
798 |row_key| row_keys.iter().position(|key| key == row_key),
799 ui_state,
800 );
801 }
802
803 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
804 let max_offset = (total_h - inner.h).max(0.0);
805 let stored = ui_state
806 .scroll
807 .offsets
808 .get(&node.computed_id)
809 .copied()
810 .unwrap_or(0.0);
811 let pin_active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
812 let provisional_offset = if pin_active {
813 max_offset
814 } else if request_wrote {
815 stored
816 } else {
817 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
818 .unwrap_or(stored)
819 }
820 .clamp(0.0, max_offset);
821
822 let (measure_start, _, measure_end) =
823 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
824 measure_dynamic_range(
825 node,
826 DynamicRangeCtx {
827 inner,
828 row_keys: &row_keys,
829 width_bucket,
830 build_row: &fns.build_row,
831 },
832 measure_start,
833 measure_end,
834 ui_state,
835 );
836
837 row_heights = dynamic_row_heights(
838 node,
839 &row_keys,
840 width_bucket,
841 estimated_row_height,
842 ui_state,
843 );
844 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
845 let max_offset = (total_h - inner.h).max(0.0);
846 let stored = ui_state
847 .scroll
848 .offsets
849 .get(&node.computed_id)
850 .copied()
851 .unwrap_or(0.0);
852 let pin_resolved = resolve_pin_end(node, stored, max_offset, ui_state);
853 let pin_active = node.pin_end
854 && ui_state
855 .scroll
856 .pin_active
857 .get(&node.computed_id)
858 .copied()
859 .unwrap_or(false);
860 let mut offset = if pin_active {
861 pin_resolved
862 } else if request_wrote {
863 stored
864 } else {
865 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
866 .unwrap_or(stored)
867 }
868 .clamp(0.0, max_offset);
869
870 ui_state
871 .scroll
872 .offsets
873 .insert(node.computed_id.clone(), offset);
874
875 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
876 let mut realized_rows = layout_dynamic_range(
877 node,
878 DynamicRangeCtx {
879 inner,
880 row_keys: &row_keys,
881 width_bucket,
882 build_row: &fns.build_row,
883 },
884 offset,
885 start,
886 start_y,
887 end,
888 ui_state,
889 );
890
891 row_heights = dynamic_row_heights(
892 node,
893 &row_keys,
894 width_bucket,
895 estimated_row_height,
896 ui_state,
897 );
898 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
899 let max_offset = (total_h - inner.h).max(0.0);
900 let corrected_offset = if pin_active {
901 max_offset
902 } else if request_wrote {
903 offset
904 } else {
905 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
906 .unwrap_or(offset)
907 }
908 .clamp(0.0, max_offset);
909 if (corrected_offset - offset).abs() > 0.01 {
910 let dy = offset - corrected_offset;
911 for child in &node.children {
912 shift_subtree_y(child, dy, ui_state);
913 }
914 for row in &mut realized_rows {
915 row.rect.y += dy;
916 }
917 offset = corrected_offset;
918 ui_state
919 .scroll
920 .offsets
921 .insert(node.computed_id.clone(), offset);
922 }
923 if node.pin_end {
924 ui_state
925 .scroll
926 .pin_prev_max
927 .insert(node.computed_id.clone(), max_offset);
928 }
929 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
930
931 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
932 ui_state
933 .scroll
934 .virtual_anchors
935 .insert(node.computed_id.clone(), anchor);
936 } else {
937 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
938 }
939}
940
941struct DynamicVirtualFns {
942 anchor_policy: VirtualAnchorPolicy,
943 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
944 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
945}
946
947#[derive(Clone, Copy)]
948struct DynamicRangeCtx<'a> {
949 inner: Rect,
950 row_keys: &'a [String],
951 width_bucket: u32,
952 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
953}
954
955fn virtual_width_bucket(width: f32) -> u32 {
956 width.max(0.0).round().min(u32::MAX as f32) as u32
957}
958
959fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
960 let Some(measurements) = ui_state
961 .scroll
962 .measured_row_heights
963 .get_mut(&node.computed_id)
964 else {
965 return;
966 };
967 let live_keys = row_keys
968 .iter()
969 .map(String::as_str)
970 .collect::<FxHashSet<_>>();
971 measurements.retain(|key, widths| {
972 let live = live_keys.contains(key.as_str());
973 if live {
974 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
975 }
976 live && !widths.is_empty()
977 });
978 if measurements.is_empty() {
979 ui_state
980 .scroll
981 .measured_row_heights
982 .remove(&node.computed_id);
983 }
984}
985
986fn dynamic_row_heights(
987 node: &El,
988 row_keys: &[String],
989 width_bucket: u32,
990 estimated_row_height: f32,
991 ui_state: &UiState,
992) -> Vec<f32> {
993 let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
994 row_keys
995 .iter()
996 .map(|key| {
997 measurements
998 .and_then(|m| m.get(key))
999 .and_then(|by_width| by_width.get(&width_bucket))
1000 .copied()
1001 .unwrap_or(estimated_row_height)
1002 })
1003 .collect()
1004}
1005
1006fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1007 row_heights
1008 .iter()
1009 .take(target)
1010 .fold(0.0, |y, h| y + *h + gap)
1011}
1012
1013fn dynamic_visible_range(
1014 row_heights: &[f32],
1015 gap: f32,
1016 offset: f32,
1017 viewport_h: f32,
1018) -> (usize, f32, usize) {
1019 let count = row_heights.len();
1020 let mut start = 0;
1021 let mut y = 0.0_f32;
1022 while start < count {
1023 let h = row_heights[start];
1024 if y + h > offset {
1025 break;
1026 }
1027 y += h + gap;
1028 start += 1;
1029 }
1030
1031 let mut end = start;
1032 let mut cursor = y;
1033 let viewport_bottom = offset + viewport_h;
1034 while end < count && cursor < viewport_bottom {
1035 let h = row_heights[end];
1036 end += 1;
1037 cursor += h + gap;
1038 }
1039 (start, y, end)
1040}
1041
1042fn dynamic_anchor_offset(
1043 node: &El,
1044 row_keys: &[String],
1045 row_heights: &[f32],
1046 gap: f32,
1047 stored: f32,
1048 ui_state: &UiState,
1049) -> Option<f32> {
1050 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1051 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1052 anchor.row_index
1053 } else {
1054 row_keys.iter().position(|key| key == &anchor.row_key)?
1055 };
1056 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1057 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1058 let scroll_delta = stored - anchor.resolved_offset;
1059 let viewport_y = anchor.viewport_y - scroll_delta;
1060 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1061}
1062
1063fn measure_dynamic_range(
1064 node: &El,
1065 ctx: DynamicRangeCtx<'_>,
1066 start: usize,
1067 end: usize,
1068 ui_state: &mut UiState,
1069) {
1070 if start >= end {
1071 return;
1072 }
1073 let mut new_measurements = Vec::new();
1074 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1075 let child = (ctx.build_row)(idx);
1076 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1077 new_measurements.push((key.clone(), actual_h));
1078 }
1079 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1080}
1081
1082fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1083 match child.height {
1084 Size::Fixed(v) => v.max(0.0),
1085 Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1086 Size::Fill(_) => panic!(
1087 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
1088 Size::Fill would absorb the viewport's height and break virtualization",
1089 node.computed_id,
1090 ),
1091 }
1092}
1093
1094fn store_dynamic_measurements(
1095 node: &El,
1096 width_bucket: u32,
1097 measurements: Vec<(String, f32)>,
1098 ui_state: &mut UiState,
1099) {
1100 if measurements.is_empty() {
1101 return;
1102 }
1103 let entry = ui_state
1104 .scroll
1105 .measured_row_heights
1106 .entry(node.computed_id.clone())
1107 .or_default();
1108 for (row_key, h) in measurements {
1109 entry.entry(row_key).or_default().insert(width_bucket, h);
1110 }
1111}
1112
1113#[derive(Clone, Debug)]
1114struct DynamicRealizedRow {
1115 index: usize,
1116 key: String,
1117 rect: Rect,
1118}
1119
1120fn layout_dynamic_range(
1121 node: &mut El,
1122 ctx: DynamicRangeCtx<'_>,
1123 offset: f32,
1124 start: usize,
1125 start_y: f32,
1126 end: usize,
1127 ui_state: &mut UiState,
1128) -> Vec<DynamicRealizedRow> {
1129 let gap = node.gap.max(0.0);
1130 let mut cursor_y = start_y;
1131 let mut realized = Vec::new();
1132 let mut realized_rows = Vec::new();
1133 let mut new_measurements = Vec::new();
1134
1135 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1136 let mut child = (ctx.build_row)(idx);
1137 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1138 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1139 new_measurements.push((key.clone(), actual_h));
1140
1141 let row_y = ctx.inner.y + cursor_y - offset;
1142 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1143 ui_state
1144 .layout
1145 .computed_rects
1146 .insert(child.computed_id.clone(), c_rect);
1147 layout_children(&mut child, c_rect, ui_state);
1148
1149 realized_rows.push(DynamicRealizedRow {
1150 index: idx,
1151 key: key.clone(),
1152 rect: c_rect,
1153 });
1154 realized.push(child);
1155 cursor_y += actual_h + gap;
1156 }
1157
1158 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1159 node.children = realized;
1160 realized_rows
1161}
1162
1163fn choose_dynamic_anchor(
1164 policy: VirtualAnchorPolicy,
1165 inner: Rect,
1166 offset: f32,
1167 rows: &[DynamicRealizedRow],
1168) -> Option<VirtualAnchor> {
1169 let visible = rows
1170 .iter()
1171 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1172 .collect::<Vec<_>>();
1173 if visible.is_empty() {
1174 return None;
1175 }
1176
1177 let chosen = match policy {
1178 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1179 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1180 visible
1181 .iter()
1182 .min_by(|a, b| {
1183 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1184 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1185 ad.total_cmp(&bd)
1186 })
1187 .copied()
1188 .map(|row| {
1189 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1190 (row.clone(), anchor_y)
1191 })
1192 }
1193 VirtualAnchorPolicy::FirstVisible => {
1194 let row = visible
1195 .iter()
1196 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1197 .or_else(|| visible.first())
1198 .copied()?;
1199 let anchor_y = row.rect.y.max(inner.y);
1200 Some((row.clone(), anchor_y))
1201 }
1202 VirtualAnchorPolicy::LastVisible => {
1203 let row = visible
1204 .iter()
1205 .rev()
1206 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1207 .or_else(|| visible.last())
1208 .copied()?;
1209 let anchor_y = row.rect.bottom().min(inner.bottom());
1210 Some((row.clone(), anchor_y))
1211 }
1212 }?;
1213
1214 let (row, anchor_y) = chosen;
1215 let row_h = row.rect.h.max(0.0);
1216 let row_fraction = if row_h > 0.0 {
1217 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1218 } else {
1219 0.0
1220 };
1221 Some(VirtualAnchor {
1222 row_key: row.key.clone(),
1223 row_index: row.index,
1224 row_fraction,
1225 viewport_y: anchor_y - inner.y,
1226 resolved_offset: offset,
1227 })
1228}
1229
1230fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1231 if y < top {
1232 top - y
1233 } else if y > bottom {
1234 y - bottom
1235 } else {
1236 0.0
1237 }
1238}
1239
1240fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1241 if count == 0 {
1242 0.0
1243 } else {
1244 row_sum + gap * count.saturating_sub(1) as f32
1245 }
1246}
1247
1248fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1256 let inner = node_rect.inset(node.padding);
1257 if node.children.is_empty() {
1258 ui_state
1259 .scroll
1260 .offsets
1261 .insert(node.computed_id.clone(), 0.0);
1262 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1263 ui_state.scroll.metrics.insert(
1264 node.computed_id.clone(),
1265 crate::state::ScrollMetrics {
1266 viewport_h: inner.h,
1267 content_h: 0.0,
1268 max_offset: 0.0,
1269 },
1270 );
1271 return;
1272 }
1273 let content_bottom = node
1274 .children
1275 .iter()
1276 .map(|c| ui_state.rect(&c.computed_id).bottom())
1277 .fold(f32::NEG_INFINITY, f32::max);
1278 let content_h = (content_bottom - inner.y).max(0.0);
1279 let max_offset = (content_h - inner.h).max(0.0);
1280
1281 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1289
1290 let stored = ui_state
1291 .scroll
1292 .offsets
1293 .get(&node.computed_id)
1294 .copied()
1295 .unwrap_or(0.0);
1296 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
1297 let pin_active = node.pin_end
1298 && ui_state
1299 .scroll
1300 .pin_active
1301 .get(&node.computed_id)
1302 .copied()
1303 .unwrap_or(false);
1304 let stored = if pin_active || request_wrote {
1305 stored
1306 } else {
1307 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
1308 };
1309 let clamped = stored.clamp(0.0, max_offset);
1310 if clamped > 0.0 {
1311 for c in &node.children {
1312 shift_subtree_y(c, -clamped, ui_state);
1313 }
1314 }
1315 ui_state
1316 .scroll
1317 .offsets
1318 .insert(node.computed_id.clone(), clamped);
1319 ui_state.scroll.metrics.insert(
1320 node.computed_id.clone(),
1321 crate::state::ScrollMetrics {
1322 viewport_h: inner.h,
1323 content_h,
1324 max_offset,
1325 },
1326 );
1327
1328 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1329
1330 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
1331 ui_state
1332 .scroll
1333 .scroll_anchors
1334 .insert(node.computed_id.clone(), anchor);
1335 } else {
1336 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1337 }
1338}
1339
1340fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
1341 let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
1342 let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
1343 if rect.h <= 0.0 {
1344 return None;
1345 }
1346 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
1347 let scroll_delta = stored - anchor.resolved_offset;
1348 let viewport_y = anchor.viewport_y - scroll_delta;
1349 Some(rect.y - inner.y + rect_point - viewport_y)
1350}
1351
1352fn choose_scroll_anchor(
1353 node: &El,
1354 inner: Rect,
1355 offset: f32,
1356 ui_state: &UiState,
1357) -> Option<ScrollAnchor> {
1358 if inner.h <= 0.0 {
1359 return None;
1360 }
1361 let target_y = inner.y + inner.h * 0.25;
1362 let mut best = None;
1363 for child in &node.children {
1364 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
1365 }
1366 let candidate = best?;
1367 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
1368 let rect_fraction = if candidate.rect.h > 0.0 {
1369 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
1370 } else {
1371 0.0
1372 };
1373 Some(ScrollAnchor {
1374 node_id: candidate.node_id,
1375 rect_fraction,
1376 viewport_y: anchor_y - inner.y,
1377 resolved_offset: offset,
1378 })
1379}
1380
1381#[derive(Clone, Debug)]
1382struct ScrollAnchorCandidate {
1383 node_id: String,
1384 rect: Rect,
1385 distance: f32,
1386 depth: usize,
1387}
1388
1389fn choose_scroll_anchor_in_subtree(
1390 node: &El,
1391 inner: Rect,
1392 target_y: f32,
1393 depth: usize,
1394 ui_state: &UiState,
1395 best: &mut Option<ScrollAnchorCandidate>,
1396) {
1397 let Some(rect) = ui_state
1398 .layout
1399 .computed_rects
1400 .get(&node.computed_id)
1401 .copied()
1402 else {
1403 return;
1404 };
1405 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
1406 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
1407 let candidate = ScrollAnchorCandidate {
1408 node_id: node.computed_id.clone(),
1409 rect,
1410 distance,
1411 depth,
1412 };
1413 let replace = best.as_ref().is_none_or(|current| {
1414 candidate.distance < current.distance
1415 || (candidate.distance == current.distance && candidate.depth > current.depth)
1416 || (candidate.distance == current.distance
1417 && candidate.depth == current.depth
1418 && candidate.rect.h < current.rect.h)
1419 });
1420 if replace {
1421 *best = Some(candidate);
1422 }
1423 }
1424
1425 if node.scrollable {
1426 return;
1427 }
1428 for child in &node.children {
1429 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
1430 }
1431}
1432
1433const PIN_END_EPSILON: f32 = 0.5;
1438
1439fn pin_end_would_be_active(
1440 node: &El,
1441 stored: f32,
1442 _max_offset: f32,
1443 ui_state: &UiState,
1444) -> Option<bool> {
1445 if !node.pin_end {
1446 return None;
1447 }
1448 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1449 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1450 Some(match prev_active {
1451 None => true,
1452 Some(prev) => {
1453 let prev_max = prev_max.unwrap_or(0.0);
1454 if prev && stored < prev_max - PIN_END_EPSILON {
1455 false
1456 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
1457 true
1458 } else {
1459 prev
1460 }
1461 }
1462 })
1463}
1464
1465fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1476 if !node.pin_end {
1477 ui_state.scroll.pin_active.remove(&node.computed_id);
1478 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1479 return stored;
1480 }
1481 let active = pin_end_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1482 ui_state
1483 .scroll
1484 .pin_active
1485 .insert(node.computed_id.clone(), active);
1486 ui_state
1487 .scroll
1488 .pin_prev_max
1489 .insert(node.computed_id.clone(), max_offset);
1490 if active { max_offset } else { stored }
1491}
1492
1493fn resolve_ensure_visible_for_scroll(
1506 node: &El,
1507 inner: Rect,
1508 content_h: f32,
1509 ui_state: &mut UiState,
1510) -> bool {
1511 if ui_state.scroll.pending_requests.is_empty() {
1512 return false;
1513 }
1514 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1515 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1516 let mut wrote = false;
1517 for req in pending {
1518 let ScrollRequest::EnsureVisible {
1519 container_key,
1520 y,
1521 h,
1522 } = &req
1523 else {
1524 remaining.push(req);
1525 continue;
1526 };
1527 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1528 remaining.push(req);
1533 continue;
1534 };
1535 let inside = node.computed_id == *ancestor_id
1538 || node
1539 .computed_id
1540 .strip_prefix(ancestor_id.as_str())
1541 .is_some_and(|rest| rest.starts_with('.'));
1542 if !inside {
1543 remaining.push(req);
1544 continue;
1545 }
1546 let current = ui_state
1547 .scroll
1548 .offsets
1549 .get(&node.computed_id)
1550 .copied()
1551 .unwrap_or(0.0);
1552 let target_top = *y;
1553 let target_bottom = *y + *h;
1554 let viewport_h = inner.h;
1555 let new_offset = if target_top < current {
1562 target_top
1563 } else if target_bottom > current + viewport_h {
1564 target_bottom - viewport_h
1565 } else {
1566 continue;
1571 };
1572 let max = (content_h - viewport_h).max(0.0);
1576 let new_offset = new_offset.clamp(0.0, max);
1577 ui_state
1578 .scroll
1579 .offsets
1580 .insert(node.computed_id.clone(), new_offset);
1581 wrote = true;
1582 }
1583 ui_state.scroll.pending_requests = remaining;
1584 wrote
1585}
1586
1587fn write_thumb_rect(
1595 node: &El,
1596 inner: Rect,
1597 content_h: f32,
1598 max_offset: f32,
1599 offset: f32,
1600 ui_state: &mut UiState,
1601) {
1602 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1603 return;
1604 }
1605 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1606 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1607 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1608 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1609 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1610 let track_remaining = (inner.h - thumb_h).max(0.0);
1611 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1612 let thumb_x = inner.right() - thumb_w - track_inset;
1613 let track_x = inner.right() - track_w - track_inset;
1614 ui_state.scroll.thumb_rects.insert(
1615 node.computed_id.clone(),
1616 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1617 );
1618 ui_state.scroll.thumb_tracks.insert(
1619 node.computed_id.clone(),
1620 Rect::new(track_x, inner.y, track_w, inner.h),
1621 );
1622}
1623
1624fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1625 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1626 rect.y += dy;
1627 }
1628 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
1629 thumb.y += dy;
1630 }
1631 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
1632 track.y += dy;
1633 }
1634 for c in &node.children {
1635 shift_subtree_y(c, dy, ui_state);
1636 }
1637}
1638
1639fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1640 let inner = node_rect.inset(node.padding);
1641 let n = node.children.len();
1642 if n == 0 {
1643 return;
1644 }
1645
1646 let total_gap = node.gap * n.saturating_sub(1) as f32;
1647 let main_extent = if vertical { inner.h } else { inner.w };
1648 let cross_extent = if vertical { inner.w } else { inner.h };
1649
1650 let intrinsics: Vec<(f32, f32)> = {
1651 crate::profile_span!("layout::axis::intrinsics");
1652 node.children
1653 .iter()
1654 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1655 .collect()
1656 };
1657
1658 let mut consumed = 0.0;
1659 let mut fill_weight_total = 0.0;
1660 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1661 match main_size_of(c, *iw, *ih, vertical) {
1662 MainSize::Resolved(v) => consumed += v,
1663 MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1664 }
1665 }
1666 let remaining = (main_extent - consumed - total_gap).max(0.0);
1667
1668 let free_after_used = if fill_weight_total == 0.0 {
1672 remaining
1673 } else {
1674 0.0
1675 };
1676 let mut cursor = match node.justify {
1677 Justify::Start => 0.0,
1678 Justify::Center => free_after_used * 0.5,
1679 Justify::End => free_after_used,
1680 Justify::SpaceBetween => 0.0,
1681 };
1682 let between_extra =
1683 if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1684 remaining / (n - 1) as f32
1685 } else {
1686 0.0
1687 };
1688 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1689
1690 crate::profile_span!("layout::axis::place");
1691 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1692 let main_size = match main_size_of(c, iw, ih, vertical) {
1693 MainSize::Resolved(v) => v,
1694 MainSize::Fill(w) => {
1695 let raw = remaining * w.max(0.001) / fill_weight_total.max(0.001);
1696 if vertical {
1697 clamp_h(c, raw)
1698 } else {
1699 clamp_w(c, raw)
1700 }
1701 }
1702 };
1703
1704 let cross_intent = if vertical { c.width } else { c.height };
1705 let cross_intrinsic = if vertical { iw } else { ih };
1706 let cross_size = match cross_intent {
1715 Size::Fixed(v) => v,
1716 Size::Hug | Size::Fill(_) => match node.align {
1717 Align::Stretch => cross_extent,
1718 Align::Start | Align::Center | Align::End => cross_intrinsic,
1719 },
1720 };
1721 let cross_size = if vertical {
1722 clamp_w(c, cross_size)
1723 } else {
1724 clamp_h(c, cross_size)
1725 };
1726
1727 let cross_off = match node.align {
1728 Align::Start | Align::Stretch => 0.0,
1729 Align::Center => (cross_extent - cross_size) * 0.5,
1730 Align::End => cross_extent - cross_size,
1731 };
1732
1733 let c_rect = if vertical {
1734 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1735 } else {
1736 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1737 };
1738 ui_state
1739 .layout
1740 .computed_rects
1741 .insert(c.computed_id.clone(), c_rect);
1742 if can_prune_scroll_child(c, c_rect, scroll_visible) {
1743 let nodes = zero_descendant_rects(c, c_rect, ui_state);
1744 record_pruned_subtree(nodes);
1745 } else {
1746 layout_children(c, c_rect, ui_state);
1747 }
1748
1749 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1750 }
1751}
1752
1753const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1754
1755fn scroll_visible_content_rect(
1756 node: &El,
1757 inner: Rect,
1758 vertical: bool,
1759 ui_state: &UiState,
1760) -> Option<Rect> {
1761 if !vertical || !node.scrollable || node.pin_end {
1762 return None;
1763 }
1764 let offset = ui_state
1765 .scroll
1766 .offsets
1767 .get(&node.computed_id)
1768 .copied()
1769 .unwrap_or(0.0)
1770 .max(0.0);
1771 Some(Rect::new(
1772 inner.x,
1773 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1774 inner.w,
1775 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1776 ))
1777}
1778
1779fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1780 let Some(visible) = visible else {
1781 return false;
1782 };
1783 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1784}
1785
1786fn subtree_is_layout_confined(node: &El) -> bool {
1787 if node.translate != (0.0, 0.0)
1788 || node.scale != 1.0
1789 || node.shadow > 0.0
1790 || node.paint_overflow != Sides::zero()
1791 || node.hit_overflow != Sides::zero()
1792 || node.layout_override.is_some()
1793 || node.virtual_items.is_some()
1794 {
1795 return false;
1796 }
1797 node.children.iter().all(subtree_is_layout_confined)
1798}
1799
1800fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1801 let mut count = 0;
1802 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1803 for child in &node.children {
1804 ui_state
1805 .layout
1806 .computed_rects
1807 .insert(child.computed_id.clone(), zero);
1808 count += 1 + zero_descendant_rects(child, zero, ui_state);
1809 }
1810 count
1811}
1812
1813fn record_pruned_subtree(nodes: u64) {
1814 INTRINSIC_CACHE.with(|cell| {
1815 if let Some(cache) = cell.borrow_mut().as_mut() {
1816 cache.prune.subtrees += 1;
1817 cache.prune.nodes += nodes;
1818 }
1819 });
1820}
1821
1822enum MainSize {
1823 Resolved(f32),
1824 Fill(f32),
1825}
1826
1827fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1828 let s = if vertical { c.height } else { c.width };
1829 let intr = if vertical { ih } else { iw };
1830 let clamp = |v: f32| {
1831 if vertical {
1832 clamp_h(c, v)
1833 } else {
1834 clamp_w(c, v)
1835 }
1836 };
1837 match s {
1838 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
1839 Size::Hug => MainSize::Resolved(clamp(intr)),
1840 Size::Fill(w) => MainSize::Fill(w),
1841 }
1842}
1843
1844fn child_intrinsic(
1845 c: &El,
1846 vertical: bool,
1847 parent_cross_extent: f32,
1848 parent_align: Align,
1849) -> (f32, f32) {
1850 if !vertical {
1851 return intrinsic(c);
1852 }
1853 let available_width = match c.width {
1854 Size::Fixed(v) => Some(v),
1855 Size::Fill(_) => Some(parent_cross_extent),
1856 Size::Hug => match parent_align {
1857 Align::Stretch => Some(parent_cross_extent),
1858 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1859 },
1860 };
1861 intrinsic_constrained(c, available_width)
1862}
1863
1864fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1865 let constrained_width = match c.width {
1872 Size::Fixed(v) => Some(v),
1873 Size::Fill(_) | Size::Hug => Some(parent.w),
1874 };
1875 let (iw, ih) = intrinsic_constrained(c, constrained_width);
1876 let w = match c.width {
1877 Size::Fixed(v) => v,
1878 Size::Hug => iw.min(parent.w),
1879 Size::Fill(_) => parent.w,
1880 };
1881 let h = match c.height {
1882 Size::Fixed(v) => v,
1883 Size::Hug => ih.min(parent.h),
1884 Size::Fill(_) => parent.h,
1885 };
1886 let w = clamp_w(c, w);
1887 let h = clamp_h(c, h);
1888 let x = match align {
1889 Align::Start | Align::Stretch => parent.x,
1890 Align::Center => parent.x + (parent.w - w) * 0.5,
1891 Align::End => parent.right() - w,
1892 };
1893 let y = match justify {
1894 Justify::Start | Justify::SpaceBetween => parent.y,
1895 Justify::Center => parent.y + (parent.h - h) * 0.5,
1896 Justify::End => parent.bottom() - h,
1897 };
1898 Rect::new(x, y, w, h)
1899}
1900
1901pub fn intrinsic(c: &El) -> (f32, f32) {
1903 intrinsic_constrained(c, None)
1904}
1905
1906fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1907 let key = intrinsic_cache_key(c, available_width);
1908 if let Some(key) = &key
1909 && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
1910 let mut slot = cell.borrow_mut();
1911 let cache = slot.as_mut()?;
1912 let cached = cache.measurements.get(key).copied();
1913 if cached.is_some() {
1914 cache.stats.hits += 1;
1915 }
1916 cached
1917 })
1918 {
1919 return cached;
1920 }
1921
1922 if key.is_some() {
1923 INTRINSIC_CACHE.with(|cell| {
1924 if let Some(cache) = cell.borrow_mut().as_mut() {
1925 cache.stats.misses += 1;
1926 }
1927 });
1928 }
1929
1930 let measured = intrinsic_constrained_uncached(c, available_width);
1931
1932 if let Some(key) = key {
1933 INTRINSIC_CACHE.with(|cell| {
1934 if let Some(cache) = cell.borrow_mut().as_mut() {
1935 cache.measurements.insert(key, measured);
1936 }
1937 });
1938 }
1939
1940 measured
1941}
1942
1943fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
1944 if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
1945 return None;
1946 }
1947 if c.computed_id.is_empty() {
1948 return None;
1949 }
1950 Some(IntrinsicCacheKey {
1951 computed_id: c.computed_id.clone(),
1952 available_width_bits: available_width.map(f32::to_bits),
1953 })
1954}
1955
1956fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
1957 if c.layout_override.is_some() {
1958 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1963 panic!(
1964 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1965 Size::Hug is not supported for custom layouts",
1966 c.computed_id,
1967 );
1968 }
1969 return apply_min(c, 0.0, 0.0);
1970 }
1971 if c.virtual_items.is_some() {
1972 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1977 panic!(
1978 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1979 Size::Hug would defeat virtualization",
1980 c.computed_id,
1981 );
1982 }
1983 return apply_min(c, 0.0, 0.0);
1984 }
1985 if matches!(c.kind, Kind::Inlines) {
1986 return inline_paragraph_intrinsic(c, available_width);
1987 }
1988 if matches!(c.kind, Kind::HardBreak) {
1989 return apply_min(c, 0.0, 0.0);
1993 }
1994 if matches!(c.kind, Kind::Math) {
1995 if let Some(expr) = &c.math {
1996 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
1997 return apply_min(
1998 c,
1999 layout.width + c.padding.left + c.padding.right,
2000 layout.height() + c.padding.top + c.padding.bottom,
2001 );
2002 }
2003 return apply_min(c, 0.0, 0.0);
2004 }
2005 if c.icon.is_some() {
2006 return apply_min(
2007 c,
2008 c.font_size + c.padding.left + c.padding.right,
2009 c.font_size + c.padding.top + c.padding.bottom,
2010 );
2011 }
2012 if let Some(img) = &c.image {
2013 let w = img.width() as f32 + c.padding.left + c.padding.right;
2017 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2018 return apply_min(c, w, h);
2019 }
2020 if let Some(text) = &c.text {
2021 let content_available = match c.text_wrap {
2022 TextWrap::NoWrap => None,
2023 TextWrap::Wrap => available_width
2024 .or(match c.width {
2025 Size::Fixed(v) => Some(v),
2026 Size::Fill(_) | Size::Hug => None,
2027 })
2028 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2029 };
2030 let display = display_text_for_measure(c, text, content_available);
2031 let layout = text_metrics::layout_text_with_line_height_and_family(
2032 &display,
2033 c.font_size,
2034 c.line_height,
2035 c.font_family,
2036 c.font_weight,
2037 c.font_mono,
2038 c.text_wrap,
2039 content_available,
2040 );
2041 let w = match (content_available, c.width) {
2042 (Some(available), Size::Hug) => {
2043 let unwrapped = text_metrics::layout_text_with_family(
2044 text,
2045 c.font_size,
2046 c.font_family,
2047 c.font_weight,
2048 c.font_mono,
2049 TextWrap::NoWrap,
2050 None,
2051 );
2052 unwrapped.width.min(available) + c.padding.left + c.padding.right
2053 }
2054 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2055 available + c.padding.left + c.padding.right
2056 }
2057 (None, _) => layout.width + c.padding.left + c.padding.right,
2058 };
2059 let h = layout.height + c.padding.top + c.padding.bottom;
2060 return apply_min(c, w, h);
2061 }
2062 match c.axis {
2063 Axis::Overlay => {
2064 let mut w: f32 = 0.0;
2065 let mut h: f32 = 0.0;
2066 for ch in &c.children {
2067 let child_available =
2068 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2069 let (cw, chh) = intrinsic_constrained(ch, child_available);
2070 w = w.max(cw);
2071 h = h.max(chh);
2072 }
2073 apply_min(
2074 c,
2075 w + c.padding.left + c.padding.right,
2076 h + c.padding.top + c.padding.bottom,
2077 )
2078 }
2079 Axis::Column => {
2080 let mut w: f32 = 0.0;
2081 let mut h: f32 = c.padding.top + c.padding.bottom;
2082 let n = c.children.len();
2083 let child_available =
2084 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2085 for (i, ch) in c.children.iter().enumerate() {
2086 let (cw, chh) = intrinsic_constrained(ch, child_available);
2087 w = w.max(cw);
2088 h += chh;
2089 if i + 1 < n {
2090 h += c.gap;
2091 }
2092 }
2093 apply_min(c, w + c.padding.left + c.padding.right, h)
2094 }
2095 Axis::Row => {
2096 let n = c.children.len();
2106 let total_gap = c.gap * n.saturating_sub(1) as f32;
2107 let inner_available = available_width
2108 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2109
2110 let mut consumed: f32 = 0.0;
2116 let mut fill_weight_total: f32 = 0.0;
2117 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2118 for ch in &c.children {
2119 match ch.width {
2120 Size::Fill(w) => {
2121 fill_weight_total += w.max(0.001);
2122 sizes.push(None);
2123 }
2124 _ => {
2125 let (cw, chh) = intrinsic(ch);
2126 consumed += cw;
2127 sizes.push(Some((cw, chh)));
2128 }
2129 }
2130 }
2131
2132 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2140 let mut w_total: f32 = c.padding.left + c.padding.right;
2141 let mut h_max: f32 = 0.0;
2142 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2143 let (cw, chh) = match slot {
2144 Some(rc) => rc,
2145 None => match (fill_remaining, fill_weight_total > 0.0) {
2146 (Some(av), true) => {
2147 let weight = match ch.width {
2148 Size::Fill(w) => w.max(0.001),
2149 _ => 1.0,
2150 };
2151 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2152 }
2153 _ => intrinsic(ch),
2154 },
2155 };
2156 w_total += cw;
2157 if i + 1 < n {
2158 w_total += c.gap;
2159 }
2160 h_max = h_max.max(chh);
2161 }
2162 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2163 }
2164 }
2165}
2166
2167pub(crate) fn text_layout(
2168 c: &El,
2169 available_width: Option<f32>,
2170) -> Option<text_metrics::TextLayout> {
2171 let text = c.text.as_ref()?;
2172 let content_available = match c.text_wrap {
2173 TextWrap::NoWrap => None,
2174 TextWrap::Wrap => available_width
2175 .or(match c.width {
2176 Size::Fixed(v) => Some(v),
2177 Size::Fill(_) | Size::Hug => None,
2178 })
2179 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2180 };
2181 let display = display_text_for_measure(c, text, content_available);
2182 Some(text_metrics::layout_text_with_line_height_and_family(
2183 &display,
2184 c.font_size,
2185 c.line_height,
2186 c.font_family,
2187 c.font_weight,
2188 c.font_mono,
2189 c.text_wrap,
2190 content_available,
2191 ))
2192}
2193
2194fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2195 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2196 (c.text_wrap, c.text_max_lines, available_width)
2197 {
2198 text_metrics::clamp_text_to_lines_with_family(
2199 text,
2200 c.font_size,
2201 c.font_family,
2202 c.font_weight,
2203 c.font_mono,
2204 width,
2205 max_lines,
2206 )
2207 } else {
2208 text.to_string()
2209 }
2210}
2211
2212fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2213 if let Size::Fixed(v) = c.width {
2214 w = v;
2215 }
2216 if let Size::Fixed(v) = c.height {
2217 h = v;
2218 }
2219 (clamp_w(c, w), clamp_h(c, h))
2220}
2221
2222pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2228 if let Some(max_w) = c.max_width {
2229 w = w.min(max_w);
2230 }
2231 if let Some(min_w) = c.min_width {
2232 w = w.max(min_w);
2233 }
2234 w.max(0.0)
2235}
2236
2237pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2239 if let Some(max_h) = c.max_height {
2240 h = h.min(max_h);
2241 }
2242 if let Some(min_h) = c.min_height {
2243 h = h.max(min_h);
2244 }
2245 h.max(0.0)
2246}
2247
2248fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2261 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2262 return inline_mixed_intrinsic(node, available_width);
2263 }
2264 let concat = concat_inline_text(&node.children);
2265 let size = inline_paragraph_size(node);
2266 let line_height = inline_paragraph_line_height(node);
2267 let content_available = match node.text_wrap {
2268 TextWrap::NoWrap => None,
2269 TextWrap::Wrap => available_width
2270 .or(match node.width {
2271 Size::Fixed(v) => Some(v),
2272 Size::Fill(_) | Size::Hug => None,
2273 })
2274 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2275 };
2276 let layout = text_metrics::layout_text_with_line_height_and_family(
2277 &concat,
2278 size,
2279 line_height,
2280 node.font_family,
2281 FontWeight::Regular,
2282 false,
2283 node.text_wrap,
2284 content_available,
2285 );
2286 let w = match (content_available, node.width) {
2287 (Some(available), Size::Hug) => {
2288 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2289 &concat,
2290 size,
2291 line_height,
2292 node.font_family,
2293 FontWeight::Regular,
2294 false,
2295 TextWrap::NoWrap,
2296 None,
2297 );
2298 unwrapped.width.min(available) + node.padding.left + node.padding.right
2299 }
2300 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2301 available + node.padding.left + node.padding.right
2302 }
2303 (None, _) => layout.width + node.padding.left + node.padding.right,
2304 };
2305 let h = layout.height + node.padding.top + node.padding.bottom;
2306 apply_min(node, w, h)
2307}
2308
2309fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2310 let wrap_width = match node.text_wrap {
2311 TextWrap::Wrap => available_width.or(match node.width {
2312 Size::Fixed(v) => Some(v),
2313 Size::Fill(_) | Size::Hug => None,
2314 }),
2315 TextWrap::NoWrap => None,
2316 }
2317 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2318
2319 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2320 node.text_wrap,
2321 wrap_width,
2322 node.font_size * 0.82,
2323 node.font_size * 0.22,
2324 node.line_height,
2325 );
2326
2327 for child in &node.children {
2328 match child.kind {
2329 Kind::HardBreak => {
2330 breaker.finish_line();
2331 continue;
2332 }
2333 Kind::Text => {
2334 let text = child.text.as_deref().unwrap_or("");
2335 for chunk in inline_text_chunks(text) {
2336 let is_space = chunk.chars().all(char::is_whitespace);
2337 if breaker.skips_leading_space(is_space) {
2338 continue;
2339 }
2340 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2341 if breaker.wraps_before(is_space, w) {
2342 breaker.finish_line();
2343 }
2344 if breaker.skips_overflowing_space(is_space, w) {
2345 continue;
2346 }
2347 breaker.push(w, ascent, descent);
2348 }
2349 continue;
2350 }
2351 _ => {}
2352 }
2353 let (w, ascent, descent) = inline_child_metrics(child);
2354 if breaker.wraps_before(false, w) {
2355 breaker.finish_line();
2356 }
2357 breaker.push(w, ascent, descent);
2358 }
2359 let measurement = breaker.finish();
2360 let w = measurement.width + node.padding.left + node.padding.right;
2361 let h = measurement.height + node.padding.top + node.padding.bottom;
2362 apply_min(node, w, h)
2363}
2364
2365fn inline_text_chunks(text: &str) -> Vec<&str> {
2366 let mut chunks = Vec::new();
2367 let mut start = 0;
2368 let mut last_space = None;
2369 for (i, ch) in text.char_indices() {
2370 let is_space = ch.is_whitespace();
2371 match last_space {
2372 None => last_space = Some(is_space),
2373 Some(prev) if prev != is_space => {
2374 chunks.push(&text[start..i]);
2375 start = i;
2376 last_space = Some(is_space);
2377 }
2378 _ => {}
2379 }
2380 }
2381 if start < text.len() {
2382 chunks.push(&text[start..]);
2383 }
2384 chunks
2385}
2386
2387fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2388 let layout = text_metrics::layout_text_with_line_height_and_family(
2389 text,
2390 child.font_size,
2391 child.line_height,
2392 child.font_family,
2393 child.font_weight,
2394 child.font_mono,
2395 TextWrap::NoWrap,
2396 None,
2397 );
2398 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2399}
2400
2401fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2402 match child.kind {
2403 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2404 Kind::Math => {
2405 if let Some(expr) = &child.math {
2406 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2407 (layout.width, layout.ascent, layout.descent)
2408 } else {
2409 (0.0, 0.0, 0.0)
2410 }
2411 }
2412 _ => (0.0, 0.0, 0.0),
2413 }
2414}
2415
2416fn concat_inline_text(children: &[El]) -> String {
2423 let mut s = String::new();
2424 for c in children {
2425 match c.kind {
2426 Kind::Text => {
2427 if let Some(t) = &c.text {
2428 s.push_str(t);
2429 }
2430 }
2431 Kind::HardBreak => s.push('\n'),
2432 _ => {}
2433 }
2434 }
2435 s
2436}
2437
2438fn inline_paragraph_size(node: &El) -> f32 {
2442 let mut size: f32 = node.font_size;
2443 for c in &node.children {
2444 if matches!(c.kind, Kind::Text) {
2445 size = size.max(c.font_size);
2446 }
2447 }
2448 size
2449}
2450
2451fn inline_paragraph_line_height(node: &El) -> f32 {
2452 let mut line_height: f32 = node.line_height;
2453 let mut max_size: f32 = node.font_size;
2454 for c in &node.children {
2455 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2456 max_size = c.font_size;
2457 line_height = c.line_height;
2458 }
2459 }
2460 line_height
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465 use super::*;
2466 use crate::state::UiState;
2467
2468 #[test]
2473 fn align_center_shrinks_fill_child_to_intrinsic() {
2474 let mut root = column([crate::row([crate::widgets::text::text("hi")
2478 .width(Size::Fixed(40.0))
2479 .height(Size::Fixed(20.0))])])
2480 .align(Align::Center)
2481 .width(Size::Fixed(200.0))
2482 .height(Size::Fixed(100.0));
2483 let mut state = UiState::new();
2484 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2485 let row_rect = state.rect(&root.children[0].computed_id);
2486 assert!(
2489 (row_rect.x - 80.0).abs() < 0.5,
2490 "expected x≈80 (centered), got {}",
2491 row_rect.x
2492 );
2493 assert!(
2494 (row_rect.w - 40.0).abs() < 0.5,
2495 "expected w≈40 (shrunk to intrinsic), got {}",
2496 row_rect.w
2497 );
2498 }
2499
2500 #[test]
2503 fn align_stretch_preserves_fill_stretch() {
2504 let mut root = column([crate::row([crate::widgets::text::text("hi")
2505 .width(Size::Fixed(40.0))
2506 .height(Size::Fixed(20.0))])])
2507 .align(Align::Stretch)
2508 .width(Size::Fixed(200.0))
2509 .height(Size::Fixed(100.0));
2510 let mut state = UiState::new();
2511 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2512 let row_rect = state.rect(&root.children[0].computed_id);
2513 assert!(
2514 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2515 "expected stretched (x=0, w=200), got x={} w={}",
2516 row_rect.x,
2517 row_rect.w
2518 );
2519 }
2520
2521 #[test]
2524 fn justify_center_centers_hug_children() {
2525 let mut root = column([crate::widgets::text::text("hi")
2526 .width(Size::Fixed(40.0))
2527 .height(Size::Fixed(20.0))])
2528 .justify(Justify::Center)
2529 .height(Size::Fill(1.0));
2530 let mut state = UiState::new();
2531 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2532 let child_rect = state.rect(&root.children[0].computed_id);
2533 assert!(
2535 (child_rect.y - 40.0).abs() < 0.5,
2536 "expected y≈40, got {}",
2537 child_rect.y
2538 );
2539 }
2540
2541 #[test]
2542 fn justify_end_pushes_to_bottom() {
2543 let mut root = column([crate::widgets::text::text("hi")
2544 .width(Size::Fixed(40.0))
2545 .height(Size::Fixed(20.0))])
2546 .justify(Justify::End)
2547 .height(Size::Fill(1.0));
2548 let mut state = UiState::new();
2549 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2550 let child_rect = state.rect(&root.children[0].computed_id);
2551 assert!(
2552 (child_rect.y - 80.0).abs() < 0.5,
2553 "expected y≈80, got {}",
2554 child_rect.y
2555 );
2556 }
2557
2558 #[test]
2562 fn justify_space_between_distributes_evenly() {
2563 let row_child = || {
2564 crate::widgets::text::text("x")
2565 .width(Size::Fixed(20.0))
2566 .height(Size::Fixed(20.0))
2567 };
2568 let mut root = column([row_child(), row_child(), row_child()])
2569 .justify(Justify::SpaceBetween)
2570 .height(Size::Fixed(200.0));
2571 let mut state = UiState::new();
2572 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2573 let y0 = state.rect(&root.children[0].computed_id).y;
2576 let y1 = state.rect(&root.children[1].computed_id).y;
2577 let y2 = state.rect(&root.children[2].computed_id).y;
2578 assert!(
2579 y0.abs() < 0.5,
2580 "first child should be flush at y=0, got {y0}"
2581 );
2582 assert!(
2583 (y1 - 90.0).abs() < 0.5,
2584 "middle child should be at y≈90, got {y1}"
2585 );
2586 assert!(
2587 (y2 - 180.0).abs() < 0.5,
2588 "last child should be flush at y≈180, got {y2}"
2589 );
2590 }
2591
2592 #[test]
2596 fn fill_weight_distributes_proportionally() {
2597 let big = crate::widgets::text::text("big")
2598 .width(Size::Fixed(40.0))
2599 .height(Size::Fill(2.0));
2600 let small = crate::widgets::text::text("small")
2601 .width(Size::Fixed(40.0))
2602 .height(Size::Fill(1.0));
2603 let mut root = column([big, small]).height(Size::Fixed(300.0));
2604 let mut state = UiState::new();
2605 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2606 let big_h = state.rect(&root.children[0].computed_id).h;
2608 let small_h = state.rect(&root.children[1].computed_id).h;
2609 assert!(
2610 (big_h - 200.0).abs() < 0.5,
2611 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2612 );
2613 assert!(
2614 (small_h - 100.0).abs() < 0.5,
2615 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2616 );
2617 }
2618
2619 #[test]
2623 fn padding_on_hug_includes_in_intrinsic() {
2624 let root = column([crate::widgets::text::text("x")
2625 .width(Size::Fixed(40.0))
2626 .height(Size::Fixed(40.0))])
2627 .padding(Sides::all(20.0));
2628 let (w, h) = intrinsic(&root);
2629 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2631 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2632 }
2633
2634 #[test]
2638 fn align_end_pins_to_cross_axis_far_edge() {
2639 let mut root = crate::row([crate::widgets::text::text("hi")
2640 .width(Size::Fixed(40.0))
2641 .height(Size::Fixed(20.0))])
2642 .align(Align::End)
2643 .width(Size::Fixed(200.0))
2644 .height(Size::Fixed(100.0));
2645 let mut state = UiState::new();
2646 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2647 let child_rect = state.rect(&root.children[0].computed_id);
2648 assert!(
2650 (child_rect.y - 80.0).abs() < 0.5,
2651 "expected y≈80 (pinned to bottom), got {}",
2652 child_rect.y
2653 );
2654 }
2655
2656 #[test]
2657 fn overlay_can_center_hug_child() {
2658 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2659 .width(Size::Fixed(200.0))
2660 .height(Size::Hug)])
2661 .align(Align::Center)
2662 .justify(Justify::Center);
2663 let mut state = UiState::new();
2664 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2665 let child_rect = state.rect(&root.children[0].computed_id);
2666 assert!(
2667 (child_rect.x - 200.0).abs() < 0.5,
2668 "expected x≈200, got {}",
2669 child_rect.x
2670 );
2671 assert!(
2672 child_rect.y > 100.0 && child_rect.y < 200.0,
2673 "expected centered y, got {}",
2674 child_rect.y
2675 );
2676 }
2677
2678 #[test]
2679 fn scroll_offset_translates_children_and_clamps_to_content() {
2680 let mut root = scroll(
2684 (0..6)
2685 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2686 )
2687 .key("list")
2688 .gap(12.0)
2689 .height(Size::Fixed(200.0));
2690 let mut state = UiState::new();
2691 assign_ids(&mut root);
2692 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2693
2694 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2695
2696 let stored = state
2698 .scroll
2699 .offsets
2700 .get(&root.computed_id)
2701 .copied()
2702 .unwrap_or(0.0);
2703 assert!(
2704 (stored - 80.0).abs() < 0.01,
2705 "offset clamped unexpectedly: {stored}"
2706 );
2707 let c0 = state.rect(&root.children[0].computed_id);
2709 assert!(
2710 (c0.y - (-80.0)).abs() < 0.01,
2711 "child 0 y = {} (expected -80)",
2712 c0.y
2713 );
2714 state
2716 .scroll
2717 .offsets
2718 .insert(root.computed_id.clone(), 9999.0);
2719 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2720 let stored = state
2721 .scroll
2722 .offsets
2723 .get(&root.computed_id)
2724 .copied()
2725 .unwrap_or(0.0);
2726 assert!(
2727 (stored - 160.0).abs() < 0.01,
2728 "overshoot clamped to {stored}"
2729 );
2730 let mut tiny =
2732 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
2733 .height(Size::Fixed(200.0));
2734 let mut tiny_state = UiState::new();
2735 assign_ids(&mut tiny);
2736 tiny_state
2737 .scroll
2738 .offsets
2739 .insert(tiny.computed_id.clone(), 50.0);
2740 layout(
2741 &mut tiny,
2742 &mut tiny_state,
2743 Rect::new(0.0, 0.0, 300.0, 200.0),
2744 );
2745 assert_eq!(
2746 tiny_state
2747 .scroll
2748 .offsets
2749 .get(&tiny.computed_id)
2750 .copied()
2751 .unwrap_or(0.0),
2752 0.0
2753 );
2754 }
2755
2756 #[test]
2757 fn scroll_layout_prunes_far_offscreen_descendants() {
2758 let far = column([crate::widgets::text::text("far row body").key("far-text")])
2759 .height(Size::Fixed(40.0));
2760 let mut root = scroll([
2761 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
2762 crate::tree::spacer().height(Size::Fixed(400.0)),
2763 far,
2764 ])
2765 .key("list")
2766 .height(Size::Fixed(80.0));
2767 let mut state = UiState::new();
2768 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
2769 let stats = take_prune_stats();
2770
2771 assert!(
2772 stats.subtrees >= 1,
2773 "expected at least one far scroll child to be pruned, got {stats:?}"
2774 );
2775 assert!(
2776 stats.nodes >= 1,
2777 "expected pruned descendants to be zeroed, got {stats:?}"
2778 );
2779 let far_text = state
2780 .rect_of_key(&root, "far-text")
2781 .expect("far text keeps a zero rect while pruned");
2782 assert_eq!(far_text.w, 0.0);
2783 assert_eq!(far_text.h, 0.0);
2784 }
2785
2786 #[test]
2787 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
2788 let make_root = || {
2789 let paragraph_text = "Variable width text wraps into a different number of lines when \
2790 the viewport narrows, which used to make a plain scroll box lose \
2791 the item the user was reading.";
2792 scroll([column((0..30).map(|i| {
2793 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
2794 .key(format!("paragraph-{i}"))
2795 }))
2796 .gap(8.0)])
2797 .key("article")
2798 .height(Size::Fixed(180.0))
2799 };
2800
2801 let mut root = make_root();
2802 let mut state = UiState::new();
2803 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
2804
2805 state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
2806 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
2807
2808 let anchor = state
2809 .scroll
2810 .scroll_anchors
2811 .get(&root.computed_id)
2812 .cloned()
2813 .expect("plain scroll should store a visible descendant anchor");
2814 let before_rect = state.rect(&anchor.node_id);
2815 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
2816 let before_offset = state.scroll_offset(&root.computed_id);
2817
2818 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
2819
2820 let after_rect = state.rect(&anchor.node_id);
2821 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
2822 let after_offset = state.scroll_offset(&root.computed_id);
2823 assert!(
2824 (after_anchor_y - before_anchor_y).abs() < 0.5,
2825 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
2826 );
2827 assert!(
2828 (after_offset - before_offset).abs() > 20.0,
2829 "offset should absorb height changes above the anchor"
2830 );
2831 }
2832
2833 #[test]
2834 fn scrollbar_thumb_size_and_position_track_overflow() {
2835 let mut root = scroll(
2838 (0..6)
2839 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2840 )
2841 .gap(12.0)
2842 .height(Size::Fixed(200.0));
2843 let mut state = UiState::new();
2844 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2845
2846 let metrics = state
2847 .scroll
2848 .metrics
2849 .get(&root.computed_id)
2850 .copied()
2851 .expect("scrollable should have metrics");
2852 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
2853 assert!((metrics.content_h - 360.0).abs() < 0.01);
2854 assert!((metrics.max_offset - 160.0).abs() < 0.01);
2855
2856 let thumb = state
2857 .scroll
2858 .thumb_rects
2859 .get(&root.computed_id)
2860 .copied()
2861 .expect("scrollable with scrollbar() and overflow gets a thumb");
2862 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
2864 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
2865 assert!(thumb.y.abs() < 0.01);
2867 assert!(
2869 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
2870 "thumb anchored at {} (expected {})",
2871 thumb.x,
2872 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
2873 );
2874
2875 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2877 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2878 let thumb = state
2879 .scroll
2880 .thumb_rects
2881 .get(&root.computed_id)
2882 .copied()
2883 .unwrap();
2884 let track_remaining = 200.0 - thumb.h;
2885 let expected_y = track_remaining * (80.0 / 160.0);
2886 assert!(
2887 (thumb.y - expected_y).abs() < 0.5,
2888 "thumb at half-scroll y = {} (expected {expected_y})",
2889 thumb.y,
2890 );
2891 }
2892
2893 #[test]
2894 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
2895 let mut root = scroll(
2899 (0..6)
2900 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2901 )
2902 .gap(12.0)
2903 .height(Size::Fixed(200.0));
2904 let mut state = UiState::new();
2905 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2906
2907 let thumb = state
2908 .scroll
2909 .thumb_rects
2910 .get(&root.computed_id)
2911 .copied()
2912 .unwrap();
2913 let track = state
2914 .scroll
2915 .thumb_tracks
2916 .get(&root.computed_id)
2917 .copied()
2918 .unwrap();
2919 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
2921 assert!(
2922 (track.right() - thumb.right()).abs() < 0.01,
2923 "track and thumb must share the right edge",
2924 );
2925 assert!(
2928 (track.h - 200.0).abs() < 0.01,
2929 "track height = {} (expected 200)",
2930 track.h,
2931 );
2932 }
2933
2934 #[test]
2935 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
2936 let mut suppressed = scroll(
2938 (0..6)
2939 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2940 )
2941 .no_scrollbar()
2942 .height(Size::Fixed(200.0));
2943 let mut state = UiState::new();
2944 layout(
2945 &mut suppressed,
2946 &mut state,
2947 Rect::new(0.0, 0.0, 300.0, 200.0),
2948 );
2949 assert!(
2950 !state
2951 .scroll
2952 .thumb_rects
2953 .contains_key(&suppressed.computed_id)
2954 );
2955
2956 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
2958 .height(Size::Fixed(200.0));
2959 let mut tiny_state = UiState::new();
2960 layout(
2961 &mut tiny,
2962 &mut tiny_state,
2963 Rect::new(0.0, 0.0, 300.0, 200.0),
2964 );
2965 assert!(
2966 !tiny_state
2967 .scroll
2968 .thumb_rects
2969 .contains_key(&tiny.computed_id)
2970 );
2971 }
2972
2973 #[test]
2974 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
2975 let make_root = || {
2976 scroll([
2977 crate::tree::spacer().height(Size::Fixed(80.0)),
2978 scroll((0..6).map(|i| {
2979 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
2980 }))
2981 .key("inner")
2982 .height(Size::Fixed(120.0)),
2983 crate::tree::spacer().height(Size::Fixed(260.0)),
2984 ])
2985 .key("outer")
2986 .height(Size::Fixed(220.0))
2987 };
2988
2989 let mut root = make_root();
2990 let mut state = UiState::new();
2991 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
2992 let inner = root
2993 .children
2994 .iter()
2995 .find(|child| child.key.as_deref() == Some("inner"))
2996 .expect("inner scroll");
2997 let inner_id = inner.computed_id.clone();
2998 let inner_rect = state.rect(&inner_id);
2999 let thumb = state
3000 .scroll
3001 .thumb_rects
3002 .get(&inner_id)
3003 .copied()
3004 .expect("inner scroll should have a thumb");
3005 let track = state
3006 .scroll
3007 .thumb_tracks
3008 .get(&inner_id)
3009 .copied()
3010 .expect("inner scroll should have a track");
3011 let thumb_rel_y = thumb.y - inner_rect.y;
3012 let track_rel_y = track.y - inner_rect.y;
3013
3014 state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3015 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3016 let inner_rect_after = state.rect(&inner_id);
3017 let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3018 let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3019
3020 assert!(
3021 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3022 "outer scroll should shift the inner viewport"
3023 );
3024 assert!(
3025 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3026 "inner thumb should stay fixed relative to its viewport"
3027 );
3028 assert!(
3029 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3030 "inner track should stay fixed relative to its viewport"
3031 );
3032 }
3033
3034 #[test]
3035 fn layout_override_places_children_at_returned_rects() {
3036 let mut root = column((0..3).map(|i| {
3038 crate::widgets::text::text(format!("dot {i}"))
3039 .width(Size::Fixed(20.0))
3040 .height(Size::Fixed(20.0))
3041 }))
3042 .width(Size::Fixed(200.0))
3043 .height(Size::Fixed(200.0))
3044 .layout(|ctx| {
3045 ctx.children
3046 .iter()
3047 .enumerate()
3048 .map(|(i, _)| {
3049 let off = i as f32 * 30.0;
3050 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3051 })
3052 .collect()
3053 });
3054 let mut state = UiState::new();
3055 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3056 let r0 = state.rect(&root.children[0].computed_id);
3057 let r1 = state.rect(&root.children[1].computed_id);
3058 let r2 = state.rect(&root.children[2].computed_id);
3059 assert_eq!((r0.x, r0.y), (0.0, 0.0));
3060 assert_eq!((r1.x, r1.y), (30.0, 30.0));
3061 assert_eq!((r2.x, r2.y), (60.0, 60.0));
3062 }
3063
3064 #[test]
3065 fn layout_override_rect_of_key_resolves_earlier_sibling() {
3066 use crate::tree::stack;
3072 let trigger_x = 40.0;
3073 let trigger_y = 20.0;
3074 let trigger_w = 60.0;
3075 let trigger_h = 30.0;
3076 let mut root = stack([
3077 crate::widgets::button::button("Open")
3079 .key("trig")
3080 .width(Size::Fixed(trigger_w))
3081 .height(Size::Fixed(trigger_h)),
3082 stack([crate::widgets::text::text("popover")
3085 .width(Size::Fixed(80.0))
3086 .height(Size::Fixed(20.0))])
3087 .width(Size::Fill(1.0))
3088 .height(Size::Fill(1.0))
3089 .layout(|ctx| {
3090 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3091 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3092 }),
3093 ])
3094 .padding(Sides::xy(trigger_x, trigger_y));
3095 let mut state = UiState::new();
3096 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3097
3098 let popover_layer = &root.children[1];
3099 let panel_id = &popover_layer.children[0].computed_id;
3100 let panel_rect = state.rect(panel_id);
3101 assert!(
3104 (panel_rect.x - trigger_x).abs() < 0.01,
3105 "popover x = {} (expected {trigger_x})",
3106 panel_rect.x,
3107 );
3108 assert!(
3109 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3110 "popover y = {} (expected {})",
3111 panel_rect.y,
3112 trigger_y + trigger_h + 4.0,
3113 );
3114 }
3115
3116 #[test]
3117 fn layout_override_rect_of_key_returns_none_for_missing_key() {
3118 let mut root = column([crate::widgets::text::text("inner")
3119 .width(Size::Fixed(40.0))
3120 .height(Size::Fixed(20.0))])
3121 .width(Size::Fixed(200.0))
3122 .height(Size::Fixed(200.0))
3123 .layout(|ctx| {
3124 assert!((ctx.rect_of_key)("nope").is_none());
3125 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3126 });
3127 let mut state = UiState::new();
3128 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3129 }
3130
3131 #[test]
3132 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3133 use crate::tree::stack;
3139 let mut root = stack([
3140 stack([crate::widgets::text::text("panel")
3141 .width(Size::Fixed(40.0))
3142 .height(Size::Fixed(20.0))])
3143 .width(Size::Fill(1.0))
3144 .height(Size::Fill(1.0))
3145 .layout(|ctx| {
3146 assert!(
3147 (ctx.rect_of_key)("later").is_none(),
3148 "later sibling's rect must not be available yet"
3149 );
3150 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3151 }),
3152 crate::widgets::button::button("after").key("later"),
3153 ]);
3154 let mut state = UiState::new();
3155 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3156 }
3157
3158 #[test]
3159 fn layout_override_measure_returns_intrinsic() {
3160 let mut root = column([crate::widgets::text::text("hi")
3162 .width(Size::Fixed(40.0))
3163 .height(Size::Fixed(20.0))])
3164 .width(Size::Fixed(200.0))
3165 .height(Size::Fixed(200.0))
3166 .layout(|ctx| {
3167 let (w, h) = (ctx.measure)(&ctx.children[0]);
3168 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3169 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3170 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3171 });
3172 let mut state = UiState::new();
3173 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3174 let r = state.rect(&root.children[0].computed_id);
3175 assert_eq!((r.w, r.h), (40.0, 20.0));
3176 }
3177
3178 #[test]
3179 #[should_panic(expected = "returned 1 rects for 2 children")]
3180 fn layout_override_length_mismatch_panics() {
3181 let mut root = column([
3182 crate::widgets::text::text("a")
3183 .width(Size::Fixed(10.0))
3184 .height(Size::Fixed(10.0)),
3185 crate::widgets::text::text("b")
3186 .width(Size::Fixed(10.0))
3187 .height(Size::Fixed(10.0)),
3188 ])
3189 .width(Size::Fixed(200.0))
3190 .height(Size::Fixed(200.0))
3191 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3192 let mut state = UiState::new();
3193 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3194 }
3195
3196 #[test]
3197 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3198 fn layout_override_hug_panics() {
3199 let mut root = column([column([crate::widgets::text::text("c")])
3203 .width(Size::Hug)
3204 .height(Size::Fixed(200.0))
3205 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3206 .width(Size::Fixed(200.0))
3207 .height(Size::Fixed(200.0));
3208 let mut state = UiState::new();
3209 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3210 }
3211
3212 #[test]
3213 fn virtual_list_realizes_only_visible_rows() {
3214 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3218 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3219 });
3220 let mut state = UiState::new();
3221 assign_ids(&mut root);
3222 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3223 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3224
3225 assert_eq!(
3226 root.children.len(),
3227 5,
3228 "expected 5 realized rows, got {}",
3229 root.children.len()
3230 );
3231 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3233 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3234 let r0 = state.rect(&root.children[0].computed_id);
3236 assert!(
3237 (r0.y - (-20.0)).abs() < 0.5,
3238 "row 2 expected y≈-20, got {}",
3239 r0.y
3240 );
3241 }
3242
3243 #[test]
3244 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
3245 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
3246 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3247 })
3248 .gap(10.0);
3249 let mut state = UiState::new();
3250 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3251
3252 assert_eq!(
3253 root.children.len(),
3254 3,
3255 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3256 );
3257 let row_1 = root
3258 .children
3259 .iter()
3260 .find(|c| c.key.as_deref() == Some("row-1"))
3261 .expect("row 1 should be realized");
3262 assert!(
3263 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3264 "gap should place row 1 at y=50"
3265 );
3266 let metrics = state
3267 .scroll
3268 .metrics
3269 .get(&root.computed_id)
3270 .expect("virtual list writes scroll metrics");
3271 assert!(
3272 (metrics.content_h - 490.0).abs() < 0.5,
3273 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3274 metrics.content_h
3275 );
3276 }
3277
3278 #[test]
3279 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3280 let make_root = || {
3281 crate::tree::virtual_list(50, 50.0, |i| {
3282 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3283 })
3284 };
3285
3286 let mut state = UiState::new();
3287 let mut root_a = make_root();
3288 assign_ids(&mut root_a);
3289 state
3291 .scroll
3292 .offsets
3293 .insert(root_a.computed_id.clone(), 250.0);
3294 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3295 let id_at_offset_a = root_a
3296 .children
3297 .iter()
3298 .find(|c| c.key.as_deref() == Some("row-5"))
3299 .unwrap()
3300 .computed_id
3301 .clone();
3302
3303 let mut root_b = make_root();
3305 assign_ids(&mut root_b);
3306 state
3307 .scroll
3308 .offsets
3309 .insert(root_b.computed_id.clone(), 200.0);
3310 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3311 let id_at_offset_b = root_b
3312 .children
3313 .iter()
3314 .find(|c| c.key.as_deref() == Some("row-5"))
3315 .unwrap()
3316 .computed_id
3317 .clone();
3318
3319 assert_eq!(
3320 id_at_offset_a, id_at_offset_b,
3321 "row-5's computed_id changed when scroll offset moved"
3322 );
3323 }
3324
3325 #[test]
3326 fn virtual_list_clamps_overshoot_offset() {
3327 let mut root =
3329 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3330 let mut state = UiState::new();
3331 assign_ids(&mut root);
3332 state
3333 .scroll
3334 .offsets
3335 .insert(root.computed_id.clone(), 9999.0);
3336 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3337 let stored = state
3338 .scroll
3339 .offsets
3340 .get(&root.computed_id)
3341 .copied()
3342 .unwrap_or(0.0);
3343 assert!(
3344 (stored - 300.0).abs() < 0.01,
3345 "expected clamp to 300, got {stored}"
3346 );
3347 }
3348
3349 #[test]
3350 fn virtual_list_empty_count_realizes_no_children() {
3351 let mut root =
3352 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3353 let mut state = UiState::new();
3354 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3355 assert_eq!(root.children.len(), 0);
3356 }
3357
3358 #[test]
3359 #[should_panic(expected = "row_height > 0.0")]
3360 fn virtual_list_zero_row_height_panics() {
3361 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3362 }
3363
3364 #[test]
3365 #[should_panic(expected = "Size::Hug would defeat virtualization")]
3366 fn virtual_list_hug_panics() {
3367 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3368 crate::widgets::text::text(format!("r{i}"))
3369 })
3370 .height(Size::Hug)])
3371 .width(Size::Fixed(300.0))
3372 .height(Size::Fixed(200.0));
3373 let mut state = UiState::new();
3374 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3375 }
3376
3377 #[test]
3378 fn virtual_list_dyn_respects_per_row_fixed_heights() {
3379 let mut root = crate::tree::virtual_list_dyn(
3383 20,
3384 50.0,
3385 |i| format!("row-{i}"),
3386 |i| {
3387 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3388 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3389 .key(format!("row-{i}"))
3390 .height(Size::Fixed(h))
3391 },
3392 );
3393 let mut state = UiState::new();
3394 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3395
3396 assert_eq!(
3397 root.children.len(),
3398 4,
3399 "expected 4 realized rows, got {}",
3400 root.children.len()
3401 );
3402 let ys: Vec<f32> = root
3404 .children
3405 .iter()
3406 .map(|c| state.rect(&c.computed_id).y)
3407 .collect();
3408 assert!(
3409 (ys[0] - 0.0).abs() < 0.5,
3410 "row 0 expected y≈0, got {}",
3411 ys[0]
3412 );
3413 assert!(
3414 (ys[1] - 40.0).abs() < 0.5,
3415 "row 1 expected y≈40, got {}",
3416 ys[1]
3417 );
3418 assert!(
3419 (ys[2] - 120.0).abs() < 0.5,
3420 "row 2 expected y≈120, got {}",
3421 ys[2]
3422 );
3423 assert!(
3424 (ys[3] - 160.0).abs() < 0.5,
3425 "row 3 expected y≈160, got {}",
3426 ys[3]
3427 );
3428 }
3429
3430 #[test]
3431 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3432 let mut root = crate::tree::virtual_list_dyn(
3433 10,
3434 40.0,
3435 |i| format!("row-{i}"),
3436 |i| {
3437 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3438 .key(format!("row-{i}"))
3439 .height(Size::Fixed(40.0))
3440 },
3441 )
3442 .gap(10.0);
3443 let mut state = UiState::new();
3444 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3445
3446 assert_eq!(
3447 root.children.len(),
3448 3,
3449 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3450 );
3451 let row_1 = root
3452 .children
3453 .iter()
3454 .find(|c| c.key.as_deref() == Some("row-1"))
3455 .expect("row 1 should be realized");
3456 assert!(
3457 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3458 "gap should place row 1 at y=50"
3459 );
3460 let metrics = state
3461 .scroll
3462 .metrics
3463 .get(&root.computed_id)
3464 .expect("virtual list writes scroll metrics");
3465 assert!(
3466 (metrics.content_h - 490.0).abs() < 0.5,
3467 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3468 metrics.content_h
3469 );
3470 }
3471
3472 #[test]
3473 fn virtual_list_dyn_caches_measured_heights() {
3474 let mut root = crate::tree::virtual_list_dyn(
3478 50,
3479 50.0,
3480 |i| format!("row-{i}"),
3481 |i| {
3482 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3483 .key(format!("row-{i}"))
3484 .height(Size::Fixed(30.0))
3485 },
3486 );
3487 let mut state = UiState::new();
3488 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3489
3490 let measured = state
3491 .scroll
3492 .measured_row_heights
3493 .get(&root.computed_id)
3494 .expect("dynamic virtual list should populate the height cache");
3495 assert!(
3499 measured.len() >= 6,
3500 "expected ≥ 6 cached row heights, got {}",
3501 measured.len()
3502 );
3503 for by_width in measured.values() {
3504 let h = by_width
3505 .get(&300)
3506 .copied()
3507 .expect("measurement should be keyed at the 300px width bucket");
3508 assert!(
3509 (h - 30.0).abs() < 0.5,
3510 "expected cached height ≈ 30, got {h}"
3511 );
3512 }
3513 }
3514
3515 #[test]
3516 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3517 let make_root = || {
3518 crate::tree::virtual_list_dyn(
3519 100,
3520 40.0,
3521 |i| format!("row-{i}"),
3522 |i| {
3523 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3524 .key(format!("row-{i}"))
3525 .height(Size::Fixed(40.0))
3526 },
3527 )
3528 };
3529 let mut root = make_root();
3530 let mut state = UiState::new();
3531 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3532
3533 state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3534 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3535
3536 let anchor = state
3537 .scroll
3538 .virtual_anchors
3539 .get(&root.computed_id)
3540 .cloned()
3541 .expect("dynamic list should store a visible anchor");
3542 let before_y = root
3543 .children
3544 .iter()
3545 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3546 .map(|child| state.rect(&child.computed_id).y)
3547 .expect("anchor row should be realized");
3548 let before_offset = state.scroll_offset(&root.computed_id);
3549
3550 state
3551 .scroll
3552 .measured_row_heights
3553 .entry(root.computed_id.clone())
3554 .or_default()
3555 .entry("row-0".to_string())
3556 .or_default()
3557 .insert(300, 120.0);
3558
3559 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3560 let after_y = root
3561 .children
3562 .iter()
3563 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3564 .map(|child| state.rect(&child.computed_id).y)
3565 .expect("anchor row should remain realized");
3566 let after_offset = state.scroll_offset(&root.computed_id);
3567
3568 assert!(
3569 (after_y - before_y).abs() < 0.5,
3570 "anchor row should stay at y={before_y}, got {after_y}"
3571 );
3572 assert!(
3573 (after_offset - (before_offset + 80.0)).abs() < 0.5,
3574 "offset should absorb the 80px measurement delta above anchor"
3575 );
3576 }
3577
3578 #[test]
3579 fn virtual_list_dyn_height_cache_is_width_bucketed() {
3580 let mut root = crate::tree::virtual_list_dyn(
3581 20,
3582 50.0,
3583 |i| format!("row-{i}"),
3584 |i| {
3585 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3586 .key(format!("row-{i}"))
3587 .height(Size::Fixed(30.0))
3588 },
3589 );
3590 let mut state = UiState::new();
3591 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3592 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3593
3594 let row_0 = state
3595 .scroll
3596 .measured_row_heights
3597 .get(&root.computed_id)
3598 .and_then(|m| m.get("row-0"))
3599 .expect("row 0 should be measured");
3600 assert!(
3601 row_0.contains_key(&300) && row_0.contains_key(&240),
3602 "expected width buckets 300 and 240, got {:?}",
3603 row_0.keys().collect::<Vec<_>>()
3604 );
3605 }
3606
3607 #[test]
3608 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3609 let make_root = || {
3614 crate::tree::virtual_list_dyn(
3615 20,
3616 50.0,
3617 |i| format!("row-{i}"),
3618 |i| {
3619 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3620 .key(format!("row-{i}"))
3621 .height(Size::Fixed(30.0))
3622 },
3623 )
3624 };
3625 let mut state = UiState::new();
3626 let mut root = make_root();
3627 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3628
3629 state
3630 .scroll
3631 .offsets
3632 .insert(root.computed_id.clone(), 9999.0);
3633 let mut root2 = make_root();
3634 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3635
3636 let measured = state
3637 .scroll
3638 .measured_row_heights
3639 .get(&root2.computed_id)
3640 .expect("dynamic virtual list should populate the height cache");
3641 let measured_sum = measured
3642 .values()
3643 .filter_map(|by_width| by_width.get(&300))
3644 .sum::<f32>();
3645 let measured_count = measured
3646 .values()
3647 .filter(|by_width| by_width.contains_key(&300))
3648 .count();
3649 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3650 let expected_max_offset = expected_total - 200.0;
3651
3652 let stored = state
3653 .scroll
3654 .offsets
3655 .get(&root2.computed_id)
3656 .copied()
3657 .unwrap_or(0.0);
3658 assert!(
3659 (stored - expected_max_offset).abs() < 0.5,
3660 "expected offset clamped to {expected_max_offset}, got {stored}"
3661 );
3662 }
3663
3664 #[test]
3665 fn virtual_list_dyn_empty_count_realizes_no_children() {
3666 let mut root = crate::tree::virtual_list_dyn(
3667 0,
3668 50.0,
3669 |i| format!("row-{i}"),
3670 |i| crate::widgets::text::text(format!("r{i}")),
3671 );
3672 let mut state = UiState::new();
3673 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3674 assert_eq!(root.children.len(), 0);
3675 }
3676
3677 #[test]
3678 #[should_panic(expected = "estimated_row_height > 0.0")]
3679 fn virtual_list_dyn_zero_estimate_panics() {
3680 let _ = crate::tree::virtual_list_dyn(
3681 10,
3682 0.0,
3683 |i| format!("row-{i}"),
3684 |i| crate::widgets::text::text(format!("r{i}")),
3685 );
3686 }
3687
3688 #[test]
3689 fn text_runs_constructor_shape_smoke() {
3690 let el = crate::tree::text_runs([
3691 crate::widgets::text::text("Hello, "),
3692 crate::widgets::text::text("world").bold(),
3693 crate::tree::hard_break(),
3694 crate::widgets::text::text("of text").italic(),
3695 ]);
3696 assert_eq!(el.kind, Kind::Inlines);
3697 assert_eq!(el.children.len(), 4);
3698 assert!(matches!(
3699 el.children[1].font_weight,
3700 FontWeight::Bold | FontWeight::Semibold
3701 ));
3702 assert_eq!(el.children[2].kind, Kind::HardBreak);
3703 assert!(el.children[3].text_italic);
3704 }
3705
3706 #[test]
3707 fn wrapped_text_hugs_multiline_height_from_available_width() {
3708 let mut root = column([crate::paragraph(
3709 "A longer sentence should wrap into multiple measured lines.",
3710 )])
3711 .width(Size::Fill(1.0))
3712 .height(Size::Hug);
3713
3714 let mut state = UiState::new();
3715 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
3716
3717 let child_rect = state.rect(&root.children[0].computed_id);
3718 assert_eq!(child_rect.w, 180.0);
3719 assert!(
3720 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3721 "expected multiline paragraph height, got {}",
3722 child_rect.h
3723 );
3724 }
3725
3726 #[test]
3727 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
3728 const PANEL_W: f32 = 240.0;
3739 const PADDING: f32 = 18.0;
3740 const GAP: f32 = 12.0;
3741
3742 let panel = column([
3743 crate::paragraph(
3744 "A long enough warning paragraph that it has to wrap onto a second line \
3745 inside this narrow panel.",
3746 ),
3747 crate::widgets::button::button("OK").key("ok"),
3748 ])
3749 .width(Size::Fixed(PANEL_W))
3750 .height(Size::Hug)
3751 .padding(Sides::all(PADDING))
3752 .gap(GAP)
3753 .align(Align::Stretch);
3754
3755 let mut root = crate::stack([panel])
3756 .width(Size::Fill(1.0))
3757 .height(Size::Fill(1.0));
3758 let mut state = UiState::new();
3759 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3760
3761 let panel_rect = state.rect(&root.children[0].computed_id);
3762 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
3763
3764 let para_rect = state.rect(&root.children[0].children[0].computed_id);
3765 let button_rect = state.rect(&root.children[0].children[1].computed_id);
3766
3767 assert!(
3770 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3771 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
3772 got h={}",
3773 para_rect.h
3774 );
3775
3776 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
3782 assert!(
3783 (bottom_padding - PADDING).abs() < 0.5,
3784 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
3785 );
3786 }
3787
3788 #[test]
3789 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
3790 const COL_W: f32 = 600.0;
3802 const GUTTER_W: f32 = 3.0;
3803
3804 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
3805 sed do eiusmod tempor incididunt ut labore et dolore magna \
3806 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
3807 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
3808
3809 let make_row = || {
3810 let gutter = El::new(Kind::Custom("gutter"))
3811 .width(Size::Fixed(GUTTER_W))
3812 .height(Size::Fill(1.0));
3813 let body = crate::paragraph(long).width(Size::Fill(1.0));
3814 crate::row([gutter, body]).width(Size::Fill(1.0))
3815 };
3816
3817 let mut root = column([make_row(), make_row()])
3818 .width(Size::Fixed(COL_W))
3819 .height(Size::Hug)
3820 .align(Align::Stretch);
3821 let mut state = UiState::new();
3822 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
3823
3824 let row0_rect = state.rect(&root.children[0].computed_id);
3825 let row1_rect = state.rect(&root.children[1].computed_id);
3826 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
3827
3828 let line_height = crate::tokens::TEXT_SM.line_height;
3833 assert!(
3834 para0_rect.h > line_height * 1.5,
3835 "paragraph should wrap to multiple lines at ~597px wide; \
3836 got h={} (line_height={})",
3837 para0_rect.h,
3838 line_height,
3839 );
3840 assert!(
3841 row0_rect.h > line_height * 1.5,
3842 "row 0 should accommodate the wrapped paragraph height; \
3843 got h={} (line_height={})",
3844 row0_rect.h,
3845 line_height,
3846 );
3847
3848 assert!(
3850 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
3851 "row 1 starts at y={} but row 0 occupies y={}..{}",
3852 row1_rect.y,
3853 row0_rect.y,
3854 row0_rect.y + row0_rect.h,
3855 );
3856 }
3857
3858 #[test]
3863 fn min_width_floors_resolved_cross_axis_size() {
3864 let mut root = column([crate::widgets::text::text("hi")
3865 .width(Size::Fixed(40.0))
3866 .height(Size::Fixed(20.0))
3867 .min_width(120.0)])
3868 .align(Align::Start)
3869 .width(Size::Fixed(500.0))
3870 .height(Size::Fixed(200.0));
3871 let mut state = UiState::new();
3872 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
3873 let child_rect = state.rect(&root.children[0].computed_id);
3874 assert!(
3875 (child_rect.w - 120.0).abs() < 0.5,
3876 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
3877 child_rect.w,
3878 );
3879 }
3880
3881 #[test]
3884 fn max_width_caps_fill_child() {
3885 let mut root = crate::row([crate::widgets::text::text("body")
3886 .width(Size::Fill(1.0))
3887 .height(Size::Fixed(20.0))
3888 .max_width(160.0)])
3889 .width(Size::Fixed(800.0))
3890 .height(Size::Fixed(40.0));
3891 let mut state = UiState::new();
3892 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
3893 let child_rect = state.rect(&root.children[0].computed_id);
3894 assert!(
3895 (child_rect.w - 160.0).abs() < 0.5,
3896 "expected Fill child capped at 160, got w={}",
3897 child_rect.w,
3898 );
3899 }
3900
3901 #[test]
3904 fn min_width_wins_over_max_width_when_conflicting() {
3905 let mut root = column([crate::widgets::text::text("x")
3906 .width(Size::Fixed(50.0))
3907 .height(Size::Fixed(20.0))
3908 .max_width(80.0)
3909 .min_width(120.0)]);
3910 let mut state = UiState::new();
3911 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
3912 let child_rect = state.rect(&root.children[0].computed_id);
3913 assert!(
3914 (child_rect.w - 120.0).abs() < 0.5,
3915 "expected min_width (120) to win over max_width (80), got w={}",
3916 child_rect.w,
3917 );
3918 }
3919
3920 #[test]
3924 fn min_height_floors_hug_column_inside_fixed_parent() {
3925 let inner = column([crate::widgets::text::text("a")
3926 .width(Size::Fixed(40.0))
3927 .height(Size::Fixed(20.0))])
3928 .width(Size::Fixed(80.0))
3929 .height(Size::Hug)
3930 .min_height(200.0);
3931 let mut root = column([inner])
3932 .align(Align::Start)
3933 .width(Size::Fixed(800.0))
3934 .height(Size::Fixed(600.0));
3935 let mut state = UiState::new();
3936 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3937 let inner_rect = state.rect(&root.children[0].computed_id);
3938 assert!(
3939 (inner_rect.h - 200.0).abs() < 0.5,
3940 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
3941 inner_rect.h,
3942 );
3943 }
3944
3945 #[test]
3947 fn max_height_caps_overlay_child_below_intrinsic() {
3948 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
3951 .width(Size::Fixed(40.0))
3952 .height(Size::Fixed(300.0))])
3953 .width(Size::Hug)
3954 .height(Size::Hug)
3955 .max_height(100.0)])
3956 .width(Size::Fixed(600.0))
3957 .height(Size::Fixed(600.0));
3958 let mut state = UiState::new();
3959 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
3960 let child_rect = state.rect(&root.children[0].computed_id);
3961 assert!(
3962 (child_rect.h - 100.0).abs() < 0.5,
3963 "expected child height capped at 100, got h={}",
3964 child_rect.h,
3965 );
3966 }
3967}