1#![warn(missing_docs)]
36
37use std::cell::RefCell;
38use std::sync::Arc;
39
40use rustc_hash::{FxHashMap, FxHashSet};
41
42use crate::scroll::{ScrollAlignment, ScrollRequest};
43use crate::state::{ScrollAnchor, UiState, VirtualAnchor};
44use crate::text::metrics as text_metrics;
45use crate::tree::*;
46
47#[derive(Clone)]
77pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
78
79impl LayoutFn {
80 pub fn new<F>(f: F) -> Self
83 where
84 F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
85 {
86 LayoutFn(Arc::new(f))
87 }
88}
89
90impl std::fmt::Debug for LayoutFn {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.write_str("LayoutFn(<fn>)")
93 }
94}
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub struct LayoutIntrinsicCacheStats {
101 pub hits: u64,
103 pub misses: u64,
105}
106
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112pub struct LayoutPruneStats {
113 pub subtrees: u64,
115 pub nodes: u64,
117}
118
119#[derive(Clone, Debug, PartialEq, Eq, Hash)]
120struct IntrinsicCacheKey {
121 computed_id: String,
122 available_width_bits: Option<u32>,
123}
124
125#[derive(Default)]
126struct IntrinsicCache {
127 measurements: FxHashMap<IntrinsicCacheKey, (f32, f32)>,
128 stats: LayoutIntrinsicCacheStats,
129 prune: LayoutPruneStats,
130}
131
132thread_local! {
133 static INTRINSIC_CACHE: RefCell<Option<IntrinsicCache>> = const { RefCell::new(None) };
134 static LAST_INTRINSIC_CACHE_STATS: RefCell<LayoutIntrinsicCacheStats> =
135 const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
136 static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
137 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
138}
139
140struct IntrinsicCacheGuard {
141 previous: Option<IntrinsicCache>,
142}
143
144impl Drop for IntrinsicCacheGuard {
145 fn drop(&mut self) {
146 INTRINSIC_CACHE.with(|cell| {
147 cell.replace(self.previous.take());
148 });
149 }
150}
151
152fn with_intrinsic_cache(f: impl FnOnce()) {
153 let previous = INTRINSIC_CACHE.with(|cell| cell.replace(Some(IntrinsicCache::default())));
154 let mut guard = IntrinsicCacheGuard { previous };
155 f();
156 let finished = INTRINSIC_CACHE.with(|cell| cell.replace(guard.previous.take()));
157 if let Some(cache) = finished {
158 LAST_INTRINSIC_CACHE_STATS.with(|stats| {
159 *stats.borrow_mut() = cache.stats;
160 });
161 LAST_PRUNE_STATS.with(|stats| {
162 *stats.borrow_mut() = cache.prune;
163 });
164 }
165 std::mem::forget(guard);
166}
167
168pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
172 LAST_INTRINSIC_CACHE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
173}
174
175pub fn take_prune_stats() -> LayoutPruneStats {
179 LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
180}
181
182#[derive(Clone, Debug)]
205pub enum VirtualMode {
206 Fixed {
208 row_height: f32,
210 },
211 Dynamic {
215 estimated_row_height: f32,
218 },
219}
220
221#[derive(Clone, Copy, Debug, PartialEq)]
225pub enum VirtualAnchorPolicy {
226 ViewportFraction {
229 y_fraction: f32,
232 },
233 FirstVisible,
236 LastVisible,
239}
240
241impl Default for VirtualAnchorPolicy {
242 fn default() -> Self {
243 Self::ViewportFraction { y_fraction: 0.25 }
244 }
245}
246
247#[derive(Clone)]
254#[non_exhaustive]
255pub struct VirtualItems {
256 pub count: usize,
258 pub mode: VirtualMode,
260 pub anchor_policy: VirtualAnchorPolicy,
263 pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
267 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
270}
271
272impl VirtualItems {
273 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
277 where
278 F: Fn(usize) -> El + Send + Sync + 'static,
279 {
280 assert!(
281 row_height > 0.0,
282 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
283 );
284 VirtualItems {
285 count,
286 mode: VirtualMode::Fixed { row_height },
287 anchor_policy: VirtualAnchorPolicy::default(),
288 row_key: Arc::new(|i| i.to_string()),
289 build_row: Arc::new(build_row),
290 }
291 }
292
293 pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
299 where
300 K: Fn(usize) -> String + Send + Sync + 'static,
301 F: Fn(usize) -> El + Send + Sync + 'static,
302 {
303 assert!(
304 estimated_row_height > 0.0,
305 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
306 );
307 VirtualItems {
308 count,
309 mode: VirtualMode::Dynamic {
310 estimated_row_height,
311 },
312 anchor_policy: VirtualAnchorPolicy::default(),
313 row_key: Arc::new(row_key),
314 build_row: Arc::new(build_row),
315 }
316 }
317
318 pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
321 self.anchor_policy = policy;
322 self
323 }
324}
325
326impl std::fmt::Debug for VirtualItems {
327 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 f.debug_struct("VirtualItems")
329 .field("count", &self.count)
330 .field("mode", &self.mode)
331 .field("anchor_policy", &self.anchor_policy)
332 .field("row_key", &"<fn>")
333 .field("build_row", &"<fn>")
334 .finish()
335 }
336}
337
338#[non_exhaustive]
343pub struct LayoutCtx<'a> {
344 pub container: Rect,
348 pub children: &'a [El],
351 pub measure: &'a dyn Fn(&El) -> (f32, f32),
355 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
362 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
368}
369
370pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
379 {
380 crate::profile_span!("layout::assign_ids");
381 assign_id(root, "root");
382 }
383 layout_post_assign(root, ui_state, viewport);
384}
385
386pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
392 with_intrinsic_cache(|| {
393 {
394 crate::profile_span!("layout::root_setup");
395 ui_state
396 .layout
397 .computed_rects
398 .insert(root.computed_id.clone(), viewport);
399 rebuild_key_index(root, ui_state);
400 ui_state.scroll.metrics.clear();
404 ui_state.scroll.thumb_rects.clear();
405 ui_state.scroll.thumb_tracks.clear();
406 ui_state.scroll.visible_ranges.clear();
407 ui_state.resize.bands.clear();
408 apply_resize_overrides(root, ui_state);
413 }
414 crate::profile_span!("layout::children");
415 layout_children(root, viewport, ui_state);
416 publish_resize_bands(root, ui_state);
418 });
419}
420
421fn resize_clamp(child: &El, axis: Axis) -> (f32, f32) {
426 let (min, max) = match axis {
427 Axis::Column => (child.min_height, child.max_height),
428 _ => (child.min_width, child.max_width),
429 };
430 let min = min.unwrap_or(0.0).max(0.0);
431 (min, max.unwrap_or(f32::INFINITY).max(min))
432}
433
434fn apply_resize_overrides(node: &mut El, ui_state: &UiState) {
440 let axis = node.axis;
441 for child in &mut node.children {
442 if child.user_resizable && !matches!(axis, Axis::Overlay) {
443 let id = child.key.as_deref().unwrap_or(&child.computed_id);
444 if let Some(&px) = ui_state.resize.overrides.get(id) {
445 let (min, max) = resize_clamp(child, axis);
446 let px = px.clamp(min, max);
447 match axis {
448 Axis::Column => child.height = Size::Fixed(px),
449 _ => child.width = Size::Fixed(px),
450 }
451 }
452 }
453 apply_resize_overrides(child, ui_state);
454 }
455}
456
457fn publish_resize_bands(node: &El, ui_state: &mut UiState) {
465 use crate::state::resize::{RESIZE_BAND_THICKNESS as T, ResizeBand};
466 let axis = node.axis;
467 if !matches!(axis, Axis::Overlay) && node.children.iter().any(|c| c.user_resizable) {
468 let parent_rect = ui_state
469 .layout
470 .computed_rects
471 .get(&node.computed_id)
472 .copied();
473 if let Some(parent_rect) = parent_rect {
474 let inner_main = match axis {
475 Axis::Column => parent_rect.h - node.padding.top - node.padding.bottom,
476 _ => parent_rect.w - node.padding.left - node.padding.right,
477 };
478 let count = node.children.len();
479 for (idx, child) in node.children.iter().enumerate() {
480 if !child.user_resizable {
481 continue;
482 }
483 let Some(rect) = ui_state
484 .layout
485 .computed_rects
486 .get(&child.computed_id)
487 .copied()
488 else {
489 continue;
490 };
491 let trailing = idx + 1 < count || count == 1;
495 let band = match (axis, trailing) {
496 (Axis::Column, true) => Rect::new(rect.x, rect.y + rect.h - T / 2.0, rect.w, T),
497 (Axis::Column, false) => Rect::new(rect.x, rect.y - T / 2.0, rect.w, T),
498 (_, true) => Rect::new(rect.x + rect.w - T / 2.0, rect.y, T, rect.h),
499 (_, false) => Rect::new(rect.x - T / 2.0, rect.y, T, rect.h),
500 };
501 let (min, max) = resize_clamp(child, axis);
502 let max = max.min(inner_main.max(min));
506 ui_state.resize.bands.push(ResizeBand {
507 id: child
508 .key
509 .clone()
510 .unwrap_or_else(|| child.computed_id.clone()),
511 key: child.key.clone(),
512 container_id: node.computed_id.clone(),
513 band,
514 axis,
515 sign: if trailing { 1.0 } else { -1.0 },
516 current: match axis {
517 Axis::Column => rect.h,
518 _ => rect.w,
519 },
520 min,
521 max,
522 });
523 }
524 }
525 }
526 for child in &node.children {
527 publish_resize_bands(child, ui_state);
528 }
529}
530
531pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
538 let role = role_token(&child.kind);
539 let suffix = match (&child.key, role) {
540 (Some(k), r) => format!("{r}[{k}]"),
541 (None, r) => format!("{r}.{child_index}"),
542 };
543 assign_id(child, &format!("{parent_id}.{suffix}"));
544}
545
546fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
551 ui_state.layout.key_index.clear();
552 let mut id_counts: rustc_hash::FxHashMap<&str, u32> = Default::default();
560 fn visit<'a>(
561 node: &'a El,
562 index: &mut rustc_hash::FxHashMap<String, String>,
563 id_counts: &mut rustc_hash::FxHashMap<&'a str, u32>,
564 ) {
565 if let Some(key) = &node.key {
566 index
567 .entry(key.clone())
568 .or_insert_with(|| node.computed_id.clone());
569 }
570 *id_counts.entry(node.computed_id.as_str()).or_insert(0) += 1;
571 for c in &node.children {
572 visit(c, index, id_counts);
573 }
574 }
575 visit(root, &mut ui_state.layout.key_index, &mut id_counts);
576 warn_duplicate_ids(&id_counts, &mut ui_state.layout.warned_duplicate_ids);
577}
578
579fn warn_duplicate_ids(
586 id_counts: &rustc_hash::FxHashMap<&str, u32>,
587 warned: &mut rustc_hash::FxHashSet<String>,
588) {
589 for (&id, &n) in id_counts {
590 if n > 1 && warned.insert(id.to_string()) {
591 log::warn!(
592 "DuplicateId: {n} nodes share id {id} — duplicate sibling key; \
593 their rects and intrinsic-cache entries collide silently (issue #64)"
594 );
595 }
596 }
597}
598
599pub fn assign_ids(root: &mut El) {
603 assign_id(root, "root");
604}
605
606fn assign_id(node: &mut El, path: &str) {
607 node.computed_id = path.to_string();
608 for (i, c) in node.children.iter_mut().enumerate() {
609 let role = role_token(&c.kind);
610 let suffix = match (&c.key, role) {
611 (Some(k), r) => format!("{r}[{k}]"),
612 (None, r) => format!("{r}.{i}"),
613 };
614 let child_path = format!("{path}.{suffix}");
615 assign_id(c, &child_path);
616 }
617}
618
619fn role_token(k: &Kind) -> &'static str {
620 match k {
621 Kind::Group => "group",
622 Kind::Card => "card",
623 Kind::Button => "button",
624 Kind::Badge => "badge",
625 Kind::Text => "text",
626 Kind::Heading => "heading",
627 Kind::Spacer => "spacer",
628 Kind::Divider => "divider",
629 Kind::Overlay => "overlay",
630 Kind::Scrim => "scrim",
631 Kind::Modal => "modal",
632 Kind::Scroll => "scroll",
633 Kind::VirtualList => "virtual_list",
634 Kind::Inlines => "inlines",
635 Kind::HardBreak => "hard_break",
636 Kind::Math => "math",
637 Kind::Image => "image",
638 Kind::Surface => "surface",
639 Kind::Vector => "vector",
640 Kind::Scene3D => "scene3d",
641 Kind::Custom(name) => name,
642 }
643}
644
645fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
646 if matches!(node.kind, Kind::Inlines) {
647 for c in &mut node.children {
655 ui_state.layout.computed_rects.insert(
656 c.computed_id.clone(),
657 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
658 );
659 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
663 }
664 return;
665 }
666 if let Some(items) = node.virtual_items.clone() {
667 layout_virtual(node, node_rect, items, ui_state);
668 return;
669 }
670 if let Some(layout_fn) = node.layout_override.clone() {
671 layout_custom(node, node_rect, layout_fn, ui_state);
672 if node.scrollable {
673 apply_scroll_offset(node, node_rect, ui_state);
674 }
675 return;
676 }
677 match node.axis {
678 Axis::Overlay => {
679 let inner = node_rect.inset(node.padding);
680 for c in &mut node.children {
681 let c_rect = overlay_rect(c, inner, node.align, node.justify);
682 ui_state
683 .layout
684 .computed_rects
685 .insert(c.computed_id.clone(), c_rect);
686 layout_children(c, c_rect, ui_state);
687 }
688 }
689 Axis::Column => layout_axis(node, node_rect, true, ui_state),
690 Axis::Row => layout_axis(node, node_rect, false, ui_state),
691 }
692 if node.scrollable {
693 apply_scroll_offset(node, node_rect, ui_state);
694 }
695}
696
697fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
698 let inner = node_rect.inset(node.padding);
699 let measure = |c: &El| intrinsic(c);
700 let key_index = &ui_state.layout.key_index;
705 let computed_rects = &ui_state.layout.computed_rects;
706 let rect_of_key = |key: &str| -> Option<Rect> {
707 let id = key_index.get(key)?;
708 computed_rects.get(id).copied()
709 };
710 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
711 let rects = (layout_fn.0)(LayoutCtx {
712 container: inner,
713 children: &node.children,
714 measure: &measure,
715 rect_of_key: &rect_of_key,
716 rect_of_id: &rect_of_id,
717 });
718 assert_eq!(
719 rects.len(),
720 node.children.len(),
721 "LayoutFn for {:?} returned {} rects for {} children",
722 node.computed_id,
723 rects.len(),
724 node.children.len(),
725 );
726 for (c, c_rect) in node.children.iter_mut().zip(rects) {
727 ui_state
728 .layout
729 .computed_rects
730 .insert(c.computed_id.clone(), c_rect);
731 layout_children(c, c_rect, ui_state);
732 }
733}
734
735fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
741 let inner = node_rect.inset(node.padding);
742 match items.mode {
743 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
744 node,
745 inner,
746 items.count,
747 row_height,
748 items.build_row,
749 ui_state,
750 ),
751 VirtualMode::Dynamic {
752 estimated_row_height,
753 } => layout_virtual_dynamic(
754 node,
755 inner,
756 items.count,
757 estimated_row_height,
758 DynamicVirtualFns {
759 anchor_policy: items.anchor_policy,
760 row_key: items.row_key,
761 build_row: items.build_row,
762 },
763 ui_state,
764 ),
765 }
766}
767
768fn resolve_scroll_requests<F, K>(
778 node: &El,
779 inner: Rect,
780 count: usize,
781 row_extent: F,
782 row_for_key: K,
783 ui_state: &mut UiState,
784) -> bool
785where
786 F: Fn(usize) -> (f32, f32),
787 K: Fn(&str) -> Option<usize>,
788{
789 if ui_state.scroll.pending_requests.is_empty() {
790 return false;
791 }
792 let Some(key) = node.key.as_deref() else {
793 return false;
794 };
795 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
796 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
797 pending.into_iter().partition(|req| match req {
798 ScrollRequest::ToRow { list_key, .. } => list_key == key,
799 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
800 ScrollRequest::EnsureVisible { .. } => false,
803 });
804 ui_state.scroll.pending_requests = remaining;
805
806 let mut wrote = false;
807 for req in matched {
808 let (row, align) = match req {
809 ScrollRequest::ToRow { row, align, .. } => (row, align),
810 ScrollRequest::ToRowKey { row_key, align, .. } => {
811 let Some(row) = row_for_key(&row_key) else {
812 continue;
813 };
814 (row, align)
815 }
816 ScrollRequest::EnsureVisible { .. } => continue,
817 };
818 if row >= count {
819 continue;
820 }
821 let (row_top, row_h) = row_extent(row);
822 let row_bottom = row_top + row_h;
823 let viewport_h = inner.h;
824 let current = ui_state
825 .scroll
826 .offsets
827 .get(&node.computed_id)
828 .copied()
829 .unwrap_or(0.0);
830 let new_offset = match align {
831 ScrollAlignment::Start => row_top,
832 ScrollAlignment::End => row_bottom - viewport_h,
833 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
834 ScrollAlignment::Visible => {
835 if row_top < current {
836 row_top
837 } else if row_bottom > current + viewport_h {
838 row_bottom - viewport_h
839 } else {
840 continue;
841 }
842 }
843 };
844 ui_state
845 .scroll
846 .offsets
847 .insert(node.computed_id.clone(), new_offset);
848 wrote = true;
849 }
850 wrote
851}
852
853fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
856 let max_offset = (total_h - inner.h).max(0.0);
857 let stored = ui_state
858 .scroll
859 .offsets
860 .get(&node.computed_id)
861 .copied()
862 .unwrap_or(0.0);
863 let stored = resolve_pin(node, stored, max_offset, ui_state);
864 let offset = stored.clamp(0.0, max_offset);
865 ui_state
866 .scroll
867 .offsets
868 .insert(node.computed_id.clone(), offset);
869 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
870 offset
871}
872
873fn write_virtual_scroll_metrics(
874 node: &El,
875 inner: Rect,
876 total_h: f32,
877 max_offset: f32,
878 offset: f32,
879 ui_state: &mut UiState,
880) {
881 ui_state.scroll.metrics.insert(
882 node.computed_id.clone(),
883 crate::state::ScrollMetrics {
884 viewport_h: inner.h,
885 content_h: total_h,
886 max_offset,
887 },
888 );
889 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
890}
891
892fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
896 let role = role_token(&child.kind);
897 let suffix = match (&child.key, role) {
898 (Some(k), r) => format!("{r}[{k}]"),
899 (None, r) => format!("{r}.{global_i}"),
900 };
901 assign_id(child, &format!("{parent_id}.{suffix}"));
902}
903
904fn layout_virtual_fixed(
905 node: &mut El,
906 inner: Rect,
907 count: usize,
908 row_height: f32,
909 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
910 ui_state: &mut UiState,
911) {
912 let gap = node.gap.max(0.0);
913 let pitch = row_height + gap;
914 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
915 resolve_scroll_requests(
916 node,
917 inner,
918 count,
919 |i| (i as f32 * pitch, row_height),
920 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
921 ui_state,
922 );
923 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
924
925 if count == 0 {
926 node.children.clear();
927 return;
928 }
929
930 let start = (offset / pitch).floor() as usize;
934 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
935
936 let mut realized: Vec<El> = Vec::new();
937 let mut realized_range: Option<(usize, usize)> = None;
938 for global_i in start..end {
939 let row_top = global_i as f32 * pitch;
940 if row_top >= offset + inner.h || row_top + row_height <= offset {
941 continue;
942 }
943 let mut child = (build_row)(global_i);
944 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
945
946 let row_y = inner.y + row_top - offset;
947 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
948 ui_state
949 .layout
950 .computed_rects
951 .insert(child.computed_id.clone(), c_rect);
952 layout_children(&mut child, c_rect, ui_state);
953 realized.push(child);
954 realized_range = Some(match realized_range {
955 None => (global_i, global_i + 1),
956 Some((s, _)) => (s, global_i + 1),
957 });
958 }
959 if let Some((s, e)) = realized_range {
960 ui_state
961 .scroll
962 .visible_ranges
963 .insert(node.computed_id.clone(), (s, e));
964 }
965 node.children = realized;
966}
967
968fn layout_virtual_dynamic(
969 node: &mut El,
970 inner: Rect,
971 count: usize,
972 estimated_row_height: f32,
973 fns: DynamicVirtualFns,
974 ui_state: &mut UiState,
975) {
976 let gap = node.gap.max(0.0);
977 let width_bucket = virtual_width_bucket(inner.w);
978 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
979 prune_dynamic_measurements(node, &row_keys, ui_state);
980
981 if count == 0 {
982 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
983 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
984 debug_assert_eq!(offset, 0.0);
985 node.children.clear();
986 return;
987 }
988
989 let mut row_heights = dynamic_row_heights(
990 node,
991 &row_keys,
992 width_bucket,
993 estimated_row_height,
994 ui_state,
995 );
996
997 let has_request = node.key.as_deref().is_some_and(|k| {
1003 ui_state.scroll.pending_requests.iter().any(|r| match r {
1004 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1005 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1006 ScrollRequest::EnsureVisible { .. } => false,
1007 })
1008 });
1009 let mut request_wrote = false;
1010 if has_request {
1011 request_wrote = resolve_scroll_requests(
1012 node,
1013 inner,
1014 count,
1015 |target| {
1016 (
1017 dynamic_row_top(&row_heights, gap, target),
1018 row_heights[target],
1019 )
1020 },
1021 |row_key| row_keys.iter().position(|key| key == row_key),
1022 ui_state,
1023 );
1024 }
1025
1026 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1027 let max_offset = (total_h - inner.h).max(0.0);
1028 let stored = ui_state
1029 .scroll
1030 .offsets
1031 .get(&node.computed_id)
1032 .copied()
1033 .unwrap_or(0.0);
1034 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1035 let provisional_offset = if pin_active {
1036 match node.pin_policy {
1037 crate::tree::PinPolicy::End => max_offset,
1038 crate::tree::PinPolicy::Start => 0.0,
1039 crate::tree::PinPolicy::None => unreachable!(),
1040 }
1041 } else if request_wrote {
1042 stored
1043 } else {
1044 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1045 .unwrap_or(stored)
1046 }
1047 .clamp(0.0, max_offset);
1048
1049 let (measure_start, _, measure_end) =
1050 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
1051 measure_dynamic_range(
1052 node,
1053 DynamicRangeCtx {
1054 inner,
1055 row_keys: &row_keys,
1056 width_bucket,
1057 build_row: &fns.build_row,
1058 },
1059 measure_start,
1060 measure_end,
1061 ui_state,
1062 );
1063
1064 row_heights = dynamic_row_heights(
1065 node,
1066 &row_keys,
1067 width_bucket,
1068 estimated_row_height,
1069 ui_state,
1070 );
1071 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1072 let max_offset = (total_h - inner.h).max(0.0);
1073 let stored = ui_state
1074 .scroll
1075 .offsets
1076 .get(&node.computed_id)
1077 .copied()
1078 .unwrap_or(0.0);
1079 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1080 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1081 && ui_state
1082 .scroll
1083 .pin_active
1084 .get(&node.computed_id)
1085 .copied()
1086 .unwrap_or(false);
1087 let mut offset = if pin_active {
1088 pin_resolved
1089 } else if request_wrote {
1090 stored
1091 } else {
1092 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1093 .unwrap_or(stored)
1094 }
1095 .clamp(0.0, max_offset);
1096
1097 ui_state
1098 .scroll
1099 .offsets
1100 .insert(node.computed_id.clone(), offset);
1101
1102 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
1103 let mut realized_rows = layout_dynamic_range(
1104 node,
1105 DynamicRangeCtx {
1106 inner,
1107 row_keys: &row_keys,
1108 width_bucket,
1109 build_row: &fns.build_row,
1110 },
1111 offset,
1112 start,
1113 start_y,
1114 end,
1115 ui_state,
1116 );
1117
1118 row_heights = dynamic_row_heights(
1119 node,
1120 &row_keys,
1121 width_bucket,
1122 estimated_row_height,
1123 ui_state,
1124 );
1125 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1126 let max_offset = (total_h - inner.h).max(0.0);
1127 let corrected_offset = if pin_active {
1128 match node.pin_policy {
1129 crate::tree::PinPolicy::End => max_offset,
1130 crate::tree::PinPolicy::Start => 0.0,
1131 crate::tree::PinPolicy::None => unreachable!(),
1132 }
1133 } else if request_wrote {
1134 offset
1135 } else {
1136 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1137 .unwrap_or(offset)
1138 }
1139 .clamp(0.0, max_offset);
1140 if (corrected_offset - offset).abs() > 0.01 {
1141 let dy = offset - corrected_offset;
1142 for child in &node.children {
1143 shift_subtree_y(child, dy, ui_state);
1144 }
1145 for row in &mut realized_rows {
1146 row.rect.y += dy;
1147 }
1148 offset = corrected_offset;
1149 ui_state
1150 .scroll
1151 .offsets
1152 .insert(node.computed_id.clone(), offset);
1153 }
1154 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1155 ui_state
1156 .scroll
1157 .pin_prev_max
1158 .insert(node.computed_id.clone(), max_offset);
1159 }
1160 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1161
1162 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1163 ui_state
1164 .scroll
1165 .virtual_anchors
1166 .insert(node.computed_id.clone(), anchor);
1167 } else {
1168 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
1169 }
1170}
1171
1172struct DynamicVirtualFns {
1173 anchor_policy: VirtualAnchorPolicy,
1174 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
1175 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1176}
1177
1178#[derive(Clone, Copy)]
1179struct DynamicRangeCtx<'a> {
1180 inner: Rect,
1181 row_keys: &'a [String],
1182 width_bucket: u32,
1183 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
1184}
1185
1186fn virtual_width_bucket(width: f32) -> u32 {
1187 width.max(0.0).round().min(u32::MAX as f32) as u32
1188}
1189
1190fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
1191 let Some(measurements) = ui_state
1192 .scroll
1193 .measured_row_heights
1194 .get_mut(&node.computed_id)
1195 else {
1196 return;
1197 };
1198 let live_keys = row_keys
1199 .iter()
1200 .map(String::as_str)
1201 .collect::<FxHashSet<_>>();
1202 measurements.retain(|key, widths| {
1203 let live = live_keys.contains(key.as_str());
1204 if live {
1205 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
1206 }
1207 live && !widths.is_empty()
1208 });
1209 if measurements.is_empty() {
1210 ui_state
1211 .scroll
1212 .measured_row_heights
1213 .remove(&node.computed_id);
1214 }
1215}
1216
1217fn dynamic_row_heights(
1218 node: &El,
1219 row_keys: &[String],
1220 width_bucket: u32,
1221 estimated_row_height: f32,
1222 ui_state: &UiState,
1223) -> Vec<f32> {
1224 let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
1225 row_keys
1226 .iter()
1227 .map(|key| {
1228 measurements
1229 .and_then(|m| m.get(key))
1230 .and_then(|by_width| by_width.get(&width_bucket))
1231 .copied()
1232 .unwrap_or(estimated_row_height)
1233 })
1234 .collect()
1235}
1236
1237fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1238 row_heights
1239 .iter()
1240 .take(target)
1241 .fold(0.0, |y, h| y + *h + gap)
1242}
1243
1244fn dynamic_visible_range(
1245 row_heights: &[f32],
1246 gap: f32,
1247 offset: f32,
1248 viewport_h: f32,
1249) -> (usize, f32, usize) {
1250 let count = row_heights.len();
1251 let mut start = 0;
1252 let mut y = 0.0_f32;
1253 while start < count {
1254 let h = row_heights[start];
1255 if y + h > offset {
1256 break;
1257 }
1258 y += h + gap;
1259 start += 1;
1260 }
1261
1262 let mut end = start;
1263 let mut cursor = y;
1264 let viewport_bottom = offset + viewport_h;
1265 while end < count && cursor < viewport_bottom {
1266 let h = row_heights[end];
1267 end += 1;
1268 cursor += h + gap;
1269 }
1270 (start, y, end)
1271}
1272
1273fn dynamic_anchor_offset(
1274 node: &El,
1275 row_keys: &[String],
1276 row_heights: &[f32],
1277 gap: f32,
1278 stored: f32,
1279 ui_state: &UiState,
1280) -> Option<f32> {
1281 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1282 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1283 anchor.row_index
1284 } else {
1285 row_keys.iter().position(|key| key == &anchor.row_key)?
1286 };
1287 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1288 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1289 let scroll_delta = stored - anchor.resolved_offset;
1290 let viewport_y = anchor.viewport_y - scroll_delta;
1291 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1292}
1293
1294fn measure_dynamic_range(
1295 node: &El,
1296 ctx: DynamicRangeCtx<'_>,
1297 start: usize,
1298 end: usize,
1299 ui_state: &mut UiState,
1300) {
1301 if start >= end {
1302 return;
1303 }
1304 let mut new_measurements = Vec::new();
1305 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1306 let mut child = (ctx.build_row)(idx);
1307 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1313 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1314 new_measurements.push((key.clone(), actual_h));
1315 }
1316 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1317}
1318
1319fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1320 match child.height {
1321 Size::Fixed(v) => v.max(0.0),
1322 Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1323 Size::Aspect(r) => (width * r).max(0.0),
1324 Size::Fill(_) => panic!(
1325 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1326 or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1327 virtualization",
1328 node.computed_id,
1329 ),
1330 }
1331}
1332
1333const MAX_WIDTH_BUCKETS_PER_ROW: usize = 8;
1340
1341fn store_dynamic_measurements(
1342 node: &El,
1343 width_bucket: u32,
1344 measurements: Vec<(String, f32)>,
1345 ui_state: &mut UiState,
1346) {
1347 if measurements.is_empty() {
1348 return;
1349 }
1350 let entry = ui_state
1351 .scroll
1352 .measured_row_heights
1353 .entry(node.computed_id.clone())
1354 .or_default();
1355 for (row_key, h) in measurements {
1356 let buckets = entry.entry(row_key).or_default();
1357 buckets.insert(width_bucket, h);
1358 if buckets.len() > MAX_WIDTH_BUCKETS_PER_ROW {
1359 let mut widths: Vec<u32> = buckets.keys().copied().collect();
1362 widths.sort_unstable_by_key(|w| (i64::from(*w) - i64::from(width_bucket)).abs());
1363 for w in widths.drain(MAX_WIDTH_BUCKETS_PER_ROW..) {
1364 buckets.remove(&w);
1365 }
1366 }
1367 }
1368}
1369
1370#[derive(Clone, Debug)]
1371struct DynamicRealizedRow {
1372 index: usize,
1373 key: String,
1374 rect: Rect,
1375}
1376
1377fn layout_dynamic_range(
1378 node: &mut El,
1379 ctx: DynamicRangeCtx<'_>,
1380 offset: f32,
1381 start: usize,
1382 start_y: f32,
1383 end: usize,
1384 ui_state: &mut UiState,
1385) -> Vec<DynamicRealizedRow> {
1386 let gap = node.gap.max(0.0);
1387 let mut cursor_y = start_y;
1388 let mut realized = Vec::new();
1389 let mut realized_rows = Vec::new();
1390 let mut new_measurements = Vec::new();
1391
1392 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1393 let mut child = (ctx.build_row)(idx);
1394 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1395 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1396 new_measurements.push((key.clone(), actual_h));
1397
1398 let row_y = ctx.inner.y + cursor_y - offset;
1399 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1400 ui_state
1401 .layout
1402 .computed_rects
1403 .insert(child.computed_id.clone(), c_rect);
1404 layout_children(&mut child, c_rect, ui_state);
1405
1406 realized_rows.push(DynamicRealizedRow {
1407 index: idx,
1408 key: key.clone(),
1409 rect: c_rect,
1410 });
1411 realized.push(child);
1412 cursor_y += actual_h + gap;
1413 }
1414
1415 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1416 if let (Some(first), Some(last)) = (realized_rows.first(), realized_rows.last()) {
1417 ui_state
1418 .scroll
1419 .visible_ranges
1420 .insert(node.computed_id.clone(), (first.index, last.index + 1));
1421 }
1422 node.children = realized;
1423 realized_rows
1424}
1425
1426fn choose_dynamic_anchor(
1427 policy: VirtualAnchorPolicy,
1428 inner: Rect,
1429 offset: f32,
1430 rows: &[DynamicRealizedRow],
1431) -> Option<VirtualAnchor> {
1432 let visible = rows
1433 .iter()
1434 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1435 .collect::<Vec<_>>();
1436 if visible.is_empty() {
1437 return None;
1438 }
1439
1440 let chosen = match policy {
1441 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1442 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1443 visible
1444 .iter()
1445 .min_by(|a, b| {
1446 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1447 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1448 ad.total_cmp(&bd)
1449 })
1450 .copied()
1451 .map(|row| {
1452 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1453 (row.clone(), anchor_y)
1454 })
1455 }
1456 VirtualAnchorPolicy::FirstVisible => {
1457 let row = visible
1458 .iter()
1459 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1460 .or_else(|| visible.first())
1461 .copied()?;
1462 let anchor_y = row.rect.y.max(inner.y);
1463 Some((row.clone(), anchor_y))
1464 }
1465 VirtualAnchorPolicy::LastVisible => {
1466 let row = visible
1467 .iter()
1468 .rev()
1469 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1470 .or_else(|| visible.last())
1471 .copied()?;
1472 let anchor_y = row.rect.bottom().min(inner.bottom());
1473 Some((row.clone(), anchor_y))
1474 }
1475 }?;
1476
1477 let (row, anchor_y) = chosen;
1478 let row_h = row.rect.h.max(0.0);
1479 let row_fraction = if row_h > 0.0 {
1480 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1481 } else {
1482 0.0
1483 };
1484 Some(VirtualAnchor {
1485 row_key: row.key.clone(),
1486 row_index: row.index,
1487 row_fraction,
1488 viewport_y: anchor_y - inner.y,
1489 resolved_offset: offset,
1490 })
1491}
1492
1493fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1494 if y < top {
1495 top - y
1496 } else if y > bottom {
1497 y - bottom
1498 } else {
1499 0.0
1500 }
1501}
1502
1503fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1504 if count == 0 {
1505 0.0
1506 } else {
1507 row_sum + gap * count.saturating_sub(1) as f32
1508 }
1509}
1510
1511fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1519 let inner = node_rect.inset(node.padding);
1520 if node.children.is_empty() {
1521 ui_state
1522 .scroll
1523 .offsets
1524 .insert(node.computed_id.clone(), 0.0);
1525 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1526 ui_state.scroll.metrics.insert(
1527 node.computed_id.clone(),
1528 crate::state::ScrollMetrics {
1529 viewport_h: inner.h,
1530 content_h: 0.0,
1531 max_offset: 0.0,
1532 },
1533 );
1534 return;
1535 }
1536 let content_bottom = node
1537 .children
1538 .iter()
1539 .map(|c| ui_state.rect(&c.computed_id).bottom())
1540 .fold(f32::NEG_INFINITY, f32::max);
1541 let content_h = (content_bottom - inner.y).max(0.0);
1542 let max_offset = (content_h - inner.h).max(0.0);
1543
1544 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1552
1553 let stored = ui_state
1554 .scroll
1555 .offsets
1556 .get(&node.computed_id)
1557 .copied()
1558 .unwrap_or(0.0);
1559 let stored = resolve_pin(node, stored, max_offset, ui_state);
1560 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1561 && ui_state
1562 .scroll
1563 .pin_active
1564 .get(&node.computed_id)
1565 .copied()
1566 .unwrap_or(false);
1567 let stored = if pin_active || request_wrote {
1568 stored
1569 } else {
1570 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
1571 };
1572 let clamped = stored.clamp(0.0, max_offset);
1573 if clamped > 0.0 {
1574 for c in &node.children {
1575 shift_subtree_y(c, -clamped, ui_state);
1576 }
1577 }
1578 ui_state
1579 .scroll
1580 .offsets
1581 .insert(node.computed_id.clone(), clamped);
1582 ui_state.scroll.metrics.insert(
1583 node.computed_id.clone(),
1584 crate::state::ScrollMetrics {
1585 viewport_h: inner.h,
1586 content_h,
1587 max_offset,
1588 },
1589 );
1590
1591 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1592
1593 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
1594 ui_state
1595 .scroll
1596 .scroll_anchors
1597 .insert(node.computed_id.clone(), anchor);
1598 } else {
1599 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1600 }
1601}
1602
1603fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
1604 let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
1605 let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
1606 if rect.h <= 0.0 {
1607 return None;
1608 }
1609 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
1610 let scroll_delta = stored - anchor.resolved_offset;
1611 let viewport_y = anchor.viewport_y - scroll_delta;
1612 Some(rect.y - inner.y + rect_point - viewport_y)
1613}
1614
1615fn choose_scroll_anchor(
1616 node: &El,
1617 inner: Rect,
1618 offset: f32,
1619 ui_state: &UiState,
1620) -> Option<ScrollAnchor> {
1621 if inner.h <= 0.0 {
1622 return None;
1623 }
1624 let target_y = inner.y + inner.h * 0.25;
1625 let mut best = None;
1626 for child in &node.children {
1627 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
1628 }
1629 let candidate = best?;
1630 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
1631 let rect_fraction = if candidate.rect.h > 0.0 {
1632 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
1633 } else {
1634 0.0
1635 };
1636 Some(ScrollAnchor {
1637 node_id: candidate.node_id,
1638 rect_fraction,
1639 viewport_y: anchor_y - inner.y,
1640 resolved_offset: offset,
1641 })
1642}
1643
1644#[derive(Clone, Debug)]
1645struct ScrollAnchorCandidate {
1646 node_id: String,
1647 rect: Rect,
1648 distance: f32,
1649 depth: usize,
1650}
1651
1652fn choose_scroll_anchor_in_subtree(
1653 node: &El,
1654 inner: Rect,
1655 target_y: f32,
1656 depth: usize,
1657 ui_state: &UiState,
1658 best: &mut Option<ScrollAnchorCandidate>,
1659) {
1660 let Some(rect) = ui_state
1661 .layout
1662 .computed_rects
1663 .get(&node.computed_id)
1664 .copied()
1665 else {
1666 return;
1667 };
1668 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
1669 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
1670 let candidate = ScrollAnchorCandidate {
1671 node_id: node.computed_id.clone(),
1672 rect,
1673 distance,
1674 depth,
1675 };
1676 let replace = best.as_ref().is_none_or(|current| {
1677 candidate.distance < current.distance
1678 || (candidate.distance == current.distance && candidate.depth > current.depth)
1679 || (candidate.distance == current.distance
1680 && candidate.depth == current.depth
1681 && candidate.rect.h < current.rect.h)
1682 });
1683 if replace {
1684 *best = Some(candidate);
1685 }
1686 }
1687
1688 if node.scrollable {
1689 return;
1690 }
1691 for child in &node.children {
1692 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
1693 }
1694}
1695
1696const PIN_EPSILON: f32 = 0.5;
1701
1702fn pin_would_be_active(
1710 node: &El,
1711 stored: f32,
1712 _max_offset: f32,
1713 ui_state: &UiState,
1714) -> Option<bool> {
1715 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1716 match node.pin_policy {
1717 crate::tree::PinPolicy::None => None,
1718 crate::tree::PinPolicy::End => {
1719 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1720 Some(match prev_active {
1721 None => true,
1722 Some(prev) => {
1723 let prev_max = prev_max.unwrap_or(0.0);
1724 if prev && stored < prev_max - PIN_EPSILON {
1725 false
1726 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
1727 true
1728 } else {
1729 prev
1730 }
1731 }
1732 })
1733 }
1734 crate::tree::PinPolicy::Start => Some(match prev_active {
1735 None => true,
1736 Some(prev) => {
1737 if prev && stored > PIN_EPSILON {
1738 false
1739 } else if !prev && stored <= PIN_EPSILON {
1740 true
1741 } else {
1742 prev
1743 }
1744 }
1745 }),
1746 }
1747}
1748
1749fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1762 if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1763 ui_state.scroll.pin_active.remove(&node.computed_id);
1764 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1765 return stored;
1766 }
1767 let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1768 ui_state
1769 .scroll
1770 .pin_active
1771 .insert(node.computed_id.clone(), active);
1772 match node.pin_policy {
1773 crate::tree::PinPolicy::End => {
1774 ui_state
1775 .scroll
1776 .pin_prev_max
1777 .insert(node.computed_id.clone(), max_offset);
1778 if active { max_offset } else { stored }
1779 }
1780 crate::tree::PinPolicy::Start => {
1781 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1785 if active { 0.0 } else { stored }
1786 }
1787 crate::tree::PinPolicy::None => unreachable!(),
1788 }
1789}
1790
1791fn resolve_ensure_visible_for_scroll(
1804 node: &El,
1805 inner: Rect,
1806 content_h: f32,
1807 ui_state: &mut UiState,
1808) -> bool {
1809 if ui_state.scroll.pending_requests.is_empty() {
1810 return false;
1811 }
1812 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1813 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1814 let mut wrote = false;
1815 for req in pending {
1816 let ScrollRequest::EnsureVisible {
1817 container_key,
1818 y,
1819 h,
1820 } = &req
1821 else {
1822 remaining.push(req);
1823 continue;
1824 };
1825 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1826 remaining.push(req);
1831 continue;
1832 };
1833 let inside = node.computed_id == *ancestor_id
1836 || node
1837 .computed_id
1838 .strip_prefix(ancestor_id.as_str())
1839 .is_some_and(|rest| rest.starts_with('.'));
1840 if !inside {
1841 remaining.push(req);
1842 continue;
1843 }
1844 let current = ui_state
1845 .scroll
1846 .offsets
1847 .get(&node.computed_id)
1848 .copied()
1849 .unwrap_or(0.0);
1850 let target_top = *y;
1851 let target_bottom = *y + *h;
1852 let viewport_h = inner.h;
1853 let new_offset = if target_top < current {
1860 target_top
1861 } else if target_bottom > current + viewport_h {
1862 target_bottom - viewport_h
1863 } else {
1864 continue;
1869 };
1870 let max = (content_h - viewport_h).max(0.0);
1874 let new_offset = new_offset.clamp(0.0, max);
1875 ui_state
1876 .scroll
1877 .offsets
1878 .insert(node.computed_id.clone(), new_offset);
1879 wrote = true;
1880 }
1881 ui_state.scroll.pending_requests = remaining;
1882 wrote
1883}
1884
1885fn write_thumb_rect(
1893 node: &El,
1894 inner: Rect,
1895 content_h: f32,
1896 max_offset: f32,
1897 offset: f32,
1898 ui_state: &mut UiState,
1899) {
1900 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1901 return;
1902 }
1903 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1904 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1905 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1906 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1907 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1908 let track_remaining = (inner.h - thumb_h).max(0.0);
1909 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1910 let edge = if node.scrollbar_gutter {
1915 inner.right() + crate::tokens::SCROLLBAR_GUTTER
1916 } else {
1917 inner.right()
1918 };
1919 let thumb_x = edge - thumb_w - track_inset;
1920 let track_x = edge - track_w - track_inset;
1921 ui_state.scroll.thumb_rects.insert(
1922 node.computed_id.clone(),
1923 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1924 );
1925 ui_state.scroll.thumb_tracks.insert(
1926 node.computed_id.clone(),
1927 Rect::new(track_x, inner.y, track_w, inner.h),
1928 );
1929}
1930
1931fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1932 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1933 rect.y += dy;
1934 }
1935 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
1936 thumb.y += dy;
1937 }
1938 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
1939 track.y += dy;
1940 }
1941 for c in &node.children {
1942 shift_subtree_y(c, dy, ui_state);
1943 }
1944}
1945
1946fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1947 let inner = node_rect.inset(node.padding);
1948 let n = node.children.len();
1949 if n == 0 {
1950 return;
1951 }
1952
1953 let total_gap = node.gap * n.saturating_sub(1) as f32;
1954 let main_extent = if vertical { inner.h } else { inner.w };
1955 let cross_extent = if vertical { inner.w } else { inner.h };
1956
1957 let intrinsics: Vec<(f32, f32)> = {
1958 crate::profile_span!("layout::axis::intrinsics");
1959 if vertical {
1960 node.children
1965 .iter()
1966 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1967 .collect()
1968 } else {
1969 row_child_intrinsics(node, main_extent)
1979 }
1980 };
1981
1982 let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
1991 let main_intent = if vertical { c.height } else { c.width };
1992 if let Size::Aspect(r) = main_intent {
1993 let cross_intent = if vertical { c.width } else { c.height };
1994 if !matches!(cross_intent, Size::Aspect(_)) {
1995 let cross_intrinsic = if vertical { iw } else { ih };
1996 let cross_size = match cross_intent {
1997 Size::Fixed(v) => v,
1998 Size::Hug | Size::Fill(_) => match node.align {
1999 Align::Stretch => cross_extent,
2000 Align::Start | Align::Center | Align::End => cross_intrinsic,
2001 },
2002 Size::Aspect(_) => unreachable!(),
2003 };
2004 let cross_size = if vertical {
2005 clamp_w(c, cross_size)
2006 } else {
2007 clamp_h(c, cross_size)
2008 };
2009 let main = cross_size * r.max(0.0);
2010 let clamped = if vertical {
2011 clamp_h(c, main)
2012 } else {
2013 clamp_w(c, main)
2014 };
2015 return MainSize::Resolved(clamped);
2016 }
2017 }
2018 main_size_of(c, iw, ih, vertical)
2019 };
2020
2021 let mut main_sizes: Vec<f32> = Vec::with_capacity(n);
2032 let mut fill_weights: Vec<Option<f32>> = Vec::with_capacity(n);
2034 let mut consumed = 0.0;
2035 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
2036 match resolve_main(c, *iw, *ih) {
2037 MainSize::Resolved(v) => {
2038 consumed += v;
2039 main_sizes.push(v);
2040 fill_weights.push(None);
2041 }
2042 MainSize::Fill(w) => {
2043 main_sizes.push(0.0);
2044 fill_weights.push(Some(w.max(0.001)));
2045 }
2046 }
2047 }
2048 let mut remaining = (main_extent - consumed - total_gap).max(0.0);
2049 loop {
2050 let flexible_weight: f32 = fill_weights.iter().flatten().sum();
2051 if flexible_weight == 0.0 {
2052 break;
2053 }
2054 let mut frozen_any = false;
2055 let mut newly_frozen = 0.0;
2056 for (i, c) in node.children.iter().enumerate() {
2057 let Some(w) = fill_weights[i] else { continue };
2058 let raw = remaining * w / flexible_weight;
2059 let clamped = if vertical {
2060 clamp_h(c, raw)
2061 } else {
2062 clamp_w(c, raw)
2063 };
2064 main_sizes[i] = clamped;
2065 if clamped != raw {
2066 fill_weights[i] = None;
2067 frozen_any = true;
2068 newly_frozen += clamped;
2069 }
2070 }
2071 if !frozen_any {
2072 remaining = 0.0;
2074 break;
2075 }
2076 remaining = (remaining - newly_frozen).max(0.0);
2077 }
2078
2079 let free_after_used = remaining;
2083 let mut cursor = match node.justify {
2084 Justify::Start => 0.0,
2085 Justify::Center => free_after_used * 0.5,
2086 Justify::End => free_after_used,
2087 Justify::SpaceBetween => 0.0,
2088 };
2089 let between_extra = if matches!(node.justify, Justify::SpaceBetween) && n > 1 {
2090 free_after_used / (n - 1) as f32
2091 } else {
2092 0.0
2093 };
2094 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
2095
2096 crate::profile_span!("layout::axis::place");
2097 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
2098 let main_size = main_sizes[i];
2099
2100 let cross_intent = if vertical { c.width } else { c.height };
2101 let cross_intrinsic = if vertical { iw } else { ih };
2102 let cross_size = match cross_intent {
2114 Size::Fixed(v) => v,
2115 Size::Aspect(r) => main_size * r,
2116 Size::Hug | Size::Fill(_) => match node.align {
2117 Align::Stretch => cross_extent,
2118 Align::Start | Align::Center | Align::End => cross_intrinsic,
2119 },
2120 };
2121 let cross_size = if vertical {
2122 clamp_w(c, cross_size)
2123 } else {
2124 clamp_h(c, cross_size)
2125 };
2126
2127 let cross_off = match node.align {
2128 Align::Start | Align::Stretch => 0.0,
2129 Align::Center => (cross_extent - cross_size) * 0.5,
2130 Align::End => cross_extent - cross_size,
2131 };
2132
2133 let c_rect = if vertical {
2134 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
2135 } else {
2136 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
2137 };
2138 ui_state
2139 .layout
2140 .computed_rects
2141 .insert(c.computed_id.clone(), c_rect);
2142 if can_prune_scroll_child(c, c_rect, scroll_visible) {
2143 let nodes = zero_descendant_rects(c, c_rect, ui_state);
2144 record_pruned_subtree(nodes);
2145 } else {
2146 layout_children(c, c_rect, ui_state);
2147 }
2148
2149 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
2150 }
2151}
2152
2153const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
2154
2155fn scroll_visible_content_rect(
2156 node: &El,
2157 inner: Rect,
2158 vertical: bool,
2159 ui_state: &UiState,
2160) -> Option<Rect> {
2161 if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2162 return None;
2163 }
2164 let offset = ui_state
2165 .scroll
2166 .offsets
2167 .get(&node.computed_id)
2168 .copied()
2169 .unwrap_or(0.0)
2170 .max(0.0);
2171 Some(Rect::new(
2172 inner.x,
2173 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
2174 inner.w,
2175 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
2176 ))
2177}
2178
2179fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
2180 let Some(visible) = visible else {
2181 return false;
2182 };
2183 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
2184}
2185
2186fn subtree_is_layout_confined(node: &El) -> bool {
2187 if node.translate != (0.0, 0.0)
2188 || node.scale != 1.0
2189 || node.shadow > 0.0
2190 || node.paint_overflow != Sides::zero()
2191 || node.hit_overflow != Sides::zero()
2192 || node.layout_override.is_some()
2193 || node.virtual_items.is_some()
2194 {
2195 return false;
2196 }
2197 node.children.iter().all(subtree_is_layout_confined)
2198}
2199
2200fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
2201 let mut count = 0;
2202 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
2203 for child in &node.children {
2204 ui_state
2205 .layout
2206 .computed_rects
2207 .insert(child.computed_id.clone(), zero);
2208 count += 1 + zero_descendant_rects(child, zero, ui_state);
2209 }
2210 count
2211}
2212
2213fn record_pruned_subtree(nodes: u64) {
2214 INTRINSIC_CACHE.with(|cell| {
2215 if let Some(cache) = cell.borrow_mut().as_mut() {
2216 cache.prune.subtrees += 1;
2217 cache.prune.nodes += nodes;
2218 }
2219 });
2220}
2221
2222enum MainSize {
2223 Resolved(f32),
2224 Fill(f32),
2225}
2226
2227fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
2228 let s = if vertical { c.height } else { c.width };
2229 let intr = if vertical { ih } else { iw };
2230 let clamp = |v: f32| {
2231 if vertical {
2232 clamp_h(c, v)
2233 } else {
2234 clamp_w(c, v)
2235 }
2236 };
2237 match s {
2238 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
2239 Size::Hug => MainSize::Resolved(clamp(intr)),
2240 Size::Fill(w) => MainSize::Fill(w),
2241 Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
2248 }
2249}
2250
2251fn child_intrinsic(
2252 c: &El,
2253 vertical: bool,
2254 parent_cross_extent: f32,
2255 parent_align: Align,
2256) -> (f32, f32) {
2257 if !vertical {
2258 return intrinsic(c);
2259 }
2260 let available_width = match c.width {
2261 Size::Fixed(v) => Some(v),
2262 Size::Fill(_) => Some(parent_cross_extent),
2263 Size::Hug => match parent_align {
2264 Align::Stretch => Some(parent_cross_extent),
2265 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
2266 },
2267 Size::Aspect(_) => Some(parent_cross_extent),
2273 };
2274 intrinsic_constrained(c, available_width)
2275}
2276
2277fn row_child_intrinsics(node: &El, inner_main_extent: f32) -> Vec<(f32, f32)> {
2286 let n = node.children.len();
2287 let total_gap = node.gap * n.saturating_sub(1) as f32;
2288
2289 let mut first: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2290 let mut consumed: f32 = 0.0;
2291 let mut fill_weight_total: f32 = 0.0;
2292 for c in &node.children {
2293 match c.width {
2294 Size::Fill(w) => {
2295 fill_weight_total += w.max(0.001);
2296 first.push(None);
2297 }
2298 _ => {
2299 let (iw, ih) = intrinsic(c);
2300 consumed += iw;
2301 first.push(Some((iw, ih)));
2302 }
2303 }
2304 }
2305
2306 let fill_remaining = (inner_main_extent - consumed - total_gap).max(0.0);
2307
2308 node.children
2309 .iter()
2310 .zip(first)
2311 .map(|(c, slot)| match slot {
2312 Some(rc) => rc,
2313 None => {
2314 let weight = match c.width {
2315 Size::Fill(w) => w.max(0.001),
2316 _ => 1.0,
2317 };
2318 let av = if fill_weight_total > 0.0 {
2319 fill_remaining * weight / fill_weight_total
2320 } else {
2321 fill_remaining
2322 };
2323 intrinsic_constrained(c, Some(av))
2324 }
2325 })
2326 .collect()
2327}
2328
2329fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
2330 let constrained_width = match c.width {
2337 Size::Fixed(v) => Some(v),
2338 Size::Fill(_) | Size::Hug => Some(parent.w),
2339 Size::Aspect(_) => None,
2342 };
2343 let (iw, ih) = intrinsic_constrained(c, constrained_width);
2344 let (w, h) = match (c.width, c.height) {
2349 (Size::Aspect(_), Size::Aspect(_)) => (iw.min(parent.w), ih.min(parent.h)),
2350 (Size::Aspect(r), _) => {
2351 let h = match c.height {
2352 Size::Fixed(v) => v,
2353 Size::Hug => ih.min(parent.h),
2354 Size::Fill(_) => parent.h,
2355 Size::Aspect(_) => unreachable!(),
2356 };
2357 (h * r, h)
2358 }
2359 (_, Size::Aspect(r)) => {
2360 let w = match c.width {
2361 Size::Fixed(v) => v,
2362 Size::Hug => iw.min(parent.w),
2363 Size::Fill(_) => parent.w,
2364 Size::Aspect(_) => unreachable!(),
2365 };
2366 (w, w * r)
2367 }
2368 _ => {
2369 let w = match c.width {
2370 Size::Fixed(v) => v,
2371 Size::Hug => iw.min(parent.w),
2372 Size::Fill(_) => parent.w,
2373 Size::Aspect(_) => unreachable!(),
2374 };
2375 let h = match c.height {
2376 Size::Fixed(v) => v,
2377 Size::Hug => ih.min(parent.h),
2378 Size::Fill(_) => parent.h,
2379 Size::Aspect(_) => unreachable!(),
2380 };
2381 (w, h)
2382 }
2383 };
2384 let w = clamp_w(c, w);
2385 let h = clamp_h(c, h);
2386 let x = match align {
2387 Align::Start | Align::Stretch => parent.x,
2388 Align::Center => parent.x + (parent.w - w) * 0.5,
2389 Align::End => parent.right() - w,
2390 };
2391 let y = match justify {
2392 Justify::Start | Justify::SpaceBetween => parent.y,
2393 Justify::Center => parent.y + (parent.h - h) * 0.5,
2394 Justify::End => parent.bottom() - h,
2395 };
2396 Rect::new(x, y, w, h)
2397}
2398
2399pub fn intrinsic(c: &El) -> (f32, f32) {
2401 intrinsic_constrained(c, None)
2402}
2403
2404fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
2405 let available_width = match c.width {
2411 Size::Fixed(v) => Some(v),
2412 _ => available_width,
2413 };
2414 let key = intrinsic_cache_key(c, available_width);
2415 if let Some(key) = &key
2416 && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
2417 let mut slot = cell.borrow_mut();
2418 let cache = slot.as_mut()?;
2419 let cached = cache.measurements.get(key).copied();
2420 if cached.is_some() {
2421 cache.stats.hits += 1;
2422 }
2423 cached
2424 })
2425 {
2426 return cached;
2427 }
2428
2429 if key.is_some() {
2430 INTRINSIC_CACHE.with(|cell| {
2431 if let Some(cache) = cell.borrow_mut().as_mut() {
2432 cache.stats.misses += 1;
2433 }
2434 });
2435 }
2436
2437 let measured = apply_aspect(
2438 c,
2439 available_width,
2440 intrinsic_constrained_uncached(c, available_width),
2441 );
2442
2443 if let Some(key) = key {
2444 INTRINSIC_CACHE.with(|cell| {
2445 if let Some(cache) = cell.borrow_mut().as_mut() {
2446 cache.measurements.insert(key, measured);
2447 }
2448 });
2449 }
2450
2451 measured
2452}
2453
2454fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
2469 match (c.width, c.height) {
2470 (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
2471 (Size::Aspect(r), _) => {
2472 (clamp_w(c, ih * r.max(0.0)), ih)
2477 }
2478 (_, Size::Aspect(r)) => {
2479 let raw_basis = match c.width {
2480 Size::Fixed(v) => v,
2481 Size::Fill(_) => available_width.unwrap_or(iw),
2482 Size::Hug | Size::Aspect(_) => iw,
2483 };
2484 let basis = clamp_w(c, raw_basis);
2488 (iw, clamp_h(c, basis * r.max(0.0)))
2489 }
2490 _ => (iw, ih),
2491 }
2492}
2493
2494fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
2495 if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
2496 return None;
2497 }
2498 if c.computed_id.is_empty() {
2499 return None;
2500 }
2501 Some(IntrinsicCacheKey {
2502 computed_id: c.computed_id.clone(),
2503 available_width_bits: available_width.map(f32::to_bits),
2504 })
2505}
2506
2507fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
2508 if c.layout_override.is_some() {
2509 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2514 panic!(
2515 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
2516 Size::Hug is not supported for custom layouts",
2517 c.computed_id,
2518 );
2519 }
2520 return apply_min(c, 0.0, 0.0);
2521 }
2522 if c.virtual_items.is_some() {
2523 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2528 panic!(
2529 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
2530 Size::Hug would defeat virtualization",
2531 c.computed_id,
2532 );
2533 }
2534 return apply_min(c, 0.0, 0.0);
2535 }
2536 if matches!(c.kind, Kind::Inlines) {
2537 return inline_paragraph_intrinsic(c, available_width);
2538 }
2539 if matches!(c.kind, Kind::HardBreak) {
2540 return apply_min(c, 0.0, 0.0);
2544 }
2545 if matches!(c.kind, Kind::Math) {
2546 if let Some(expr) = &c.math {
2547 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
2548 return apply_min(
2549 c,
2550 layout.width + c.padding.left + c.padding.right,
2551 layout.height() + c.padding.top + c.padding.bottom,
2552 );
2553 }
2554 return apply_min(c, 0.0, 0.0);
2555 }
2556 if c.icon.is_some() {
2557 return apply_min(
2558 c,
2559 c.font_size + c.padding.left + c.padding.right,
2560 c.font_size + c.padding.top + c.padding.bottom,
2561 );
2562 }
2563 if let Some(img) = &c.image {
2564 let w = img.width() as f32 + c.padding.left + c.padding.right;
2568 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2569 return apply_min(c, w, h);
2570 }
2571 if let Some(text) = &c.text {
2572 let content_available = match c.text_wrap {
2573 TextWrap::NoWrap => None,
2574 TextWrap::Wrap => available_width
2575 .or(match c.width {
2576 Size::Fixed(v) => Some(v),
2577 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2581 })
2582 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2583 };
2584 let display = display_text_for_measure(c, text, content_available);
2585 let layout = text_metrics::layout_text_with_line_height_and_family(
2586 &display,
2587 c.font_size,
2588 c.line_height,
2589 c.font_family,
2590 c.font_weight,
2591 c.font_mono,
2592 c.text_tabular_numerals,
2593 c.text_wrap,
2594 content_available,
2595 );
2596 let w = match (content_available, c.width) {
2597 (Some(available), Size::Hug | Size::Aspect(_)) => {
2598 let unwrapped = text_metrics::layout_text_with_family(
2599 text,
2600 c.font_size,
2601 c.font_family,
2602 c.font_weight,
2603 c.font_mono,
2604 c.text_tabular_numerals,
2605 TextWrap::NoWrap,
2606 None,
2607 );
2608 unwrapped.width.min(available) + c.padding.left + c.padding.right
2609 }
2610 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2611 available + c.padding.left + c.padding.right
2612 }
2613 (None, _) => layout.width + c.padding.left + c.padding.right,
2614 };
2615 let h = layout.height + c.padding.top + c.padding.bottom;
2616 return apply_min(c, w, h);
2617 }
2618 match c.axis {
2619 Axis::Overlay => {
2620 let mut w: f32 = 0.0;
2621 let mut h: f32 = 0.0;
2622 for ch in &c.children {
2623 let child_available =
2624 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2625 let (cw, chh) = intrinsic_constrained(ch, child_available);
2626 w = w.max(cw);
2627 h = h.max(chh);
2628 }
2629 apply_min(
2630 c,
2631 w + c.padding.left + c.padding.right,
2632 h + c.padding.top + c.padding.bottom,
2633 )
2634 }
2635 Axis::Column => {
2636 let mut w: f32 = 0.0;
2637 let mut h: f32 = c.padding.top + c.padding.bottom;
2638 let n = c.children.len();
2639 let child_available =
2640 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2641 for (i, ch) in c.children.iter().enumerate() {
2642 let (cw, chh) = intrinsic_constrained(ch, child_available);
2643 w = w.max(cw);
2644 h += chh;
2645 if i + 1 < n {
2646 h += c.gap;
2647 }
2648 }
2649 apply_min(c, w + c.padding.left + c.padding.right, h)
2650 }
2651 Axis::Row => {
2652 let n = c.children.len();
2662 let total_gap = c.gap * n.saturating_sub(1) as f32;
2663 let inner_available = available_width
2664 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2665
2666 let mut consumed: f32 = 0.0;
2672 let mut fill_weight_total: f32 = 0.0;
2673 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2674 for ch in &c.children {
2675 match ch.width {
2676 Size::Fill(w) => {
2677 fill_weight_total += w.max(0.001);
2678 sizes.push(None);
2679 }
2680 _ => {
2681 let (cw, chh) = intrinsic(ch);
2682 consumed += cw;
2683 sizes.push(Some((cw, chh)));
2684 }
2685 }
2686 }
2687
2688 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2696 let mut w_total: f32 = c.padding.left + c.padding.right;
2697 let mut h_max: f32 = 0.0;
2698 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2699 let (cw, chh) = match slot {
2700 Some(rc) => rc,
2701 None => match (fill_remaining, fill_weight_total > 0.0) {
2702 (Some(av), true) => {
2703 let weight = match ch.width {
2704 Size::Fill(w) => w.max(0.001),
2705 _ => 1.0,
2706 };
2707 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2708 }
2709 _ => intrinsic(ch),
2710 },
2711 };
2712 w_total += cw;
2713 if i + 1 < n {
2714 w_total += c.gap;
2715 }
2716 h_max = h_max.max(chh);
2717 }
2718 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2719 }
2720 }
2721}
2722
2723pub(crate) fn text_layout(
2724 c: &El,
2725 available_width: Option<f32>,
2726) -> Option<text_metrics::TextLayout> {
2727 let text = c.text.as_ref()?;
2728 let content_available = match c.text_wrap {
2729 TextWrap::NoWrap => None,
2730 TextWrap::Wrap => available_width
2731 .or(match c.width {
2732 Size::Fixed(v) => Some(v),
2733 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2734 })
2735 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2736 };
2737 let display = display_text_for_measure(c, text, content_available);
2738 Some(text_metrics::layout_text_with_line_height_and_family(
2739 &display,
2740 c.font_size,
2741 c.line_height,
2742 c.font_family,
2743 c.font_weight,
2744 c.font_mono,
2745 c.text_tabular_numerals,
2746 c.text_wrap,
2747 content_available,
2748 ))
2749}
2750
2751fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2752 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2753 (c.text_wrap, c.text_max_lines, available_width)
2754 {
2755 text_metrics::clamp_text_to_lines_with_family(
2756 text,
2757 c.font_size,
2758 c.font_family,
2759 c.font_weight,
2760 c.font_mono,
2761 width,
2762 max_lines,
2763 )
2764 } else {
2765 text.to_string()
2766 }
2767}
2768
2769fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2770 if let Size::Fixed(v) = c.width {
2771 w = v;
2772 }
2773 if let Size::Fixed(v) = c.height {
2774 h = v;
2775 }
2776 (clamp_w(c, w), clamp_h(c, h))
2777}
2778
2779pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2785 if let Some(max_w) = c.max_width {
2786 w = w.min(max_w);
2787 }
2788 if let Some(min_w) = c.min_width {
2789 w = w.max(min_w);
2790 }
2791 w.max(0.0)
2792}
2793
2794pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2796 if let Some(max_h) = c.max_height {
2797 h = h.min(max_h);
2798 }
2799 if let Some(min_h) = c.min_height {
2800 h = h.max(min_h);
2801 }
2802 h.max(0.0)
2803}
2804
2805fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2818 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2819 return inline_mixed_intrinsic(node, available_width);
2820 }
2821 let concat = concat_inline_text(&node.children);
2822 let size = inline_paragraph_size(node);
2823 let line_height = inline_paragraph_line_height(node);
2824 let content_available = match node.text_wrap {
2825 TextWrap::NoWrap => None,
2826 TextWrap::Wrap => available_width
2827 .or(match node.width {
2828 Size::Fixed(v) => Some(v),
2829 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2830 })
2831 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2832 };
2833 let layout = text_metrics::layout_text_with_line_height_and_family(
2834 &concat,
2835 size,
2836 line_height,
2837 node.font_family,
2838 FontWeight::Regular,
2839 false,
2840 false,
2841 node.text_wrap,
2842 content_available,
2843 );
2844 let w = match (content_available, node.width) {
2845 (Some(available), Size::Hug | Size::Aspect(_)) => {
2846 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2847 &concat,
2848 size,
2849 line_height,
2850 node.font_family,
2851 FontWeight::Regular,
2852 false,
2853 false,
2854 TextWrap::NoWrap,
2855 None,
2856 );
2857 unwrapped.width.min(available) + node.padding.left + node.padding.right
2858 }
2859 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2860 available + node.padding.left + node.padding.right
2861 }
2862 (None, _) => layout.width + node.padding.left + node.padding.right,
2863 };
2864 let h = layout.height + node.padding.top + node.padding.bottom;
2865 apply_min(node, w, h)
2866}
2867
2868fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2869 let wrap_width = match node.text_wrap {
2870 TextWrap::Wrap => available_width.or(match node.width {
2871 Size::Fixed(v) => Some(v),
2872 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2873 }),
2874 TextWrap::NoWrap => None,
2875 }
2876 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2877
2878 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2879 node.text_wrap,
2880 wrap_width,
2881 node.font_size * 0.82,
2882 node.font_size * 0.22,
2883 node.line_height,
2884 );
2885
2886 for child in &node.children {
2887 match child.kind {
2888 Kind::HardBreak => {
2889 breaker.finish_line();
2890 continue;
2891 }
2892 Kind::Text => {
2893 let text = child.text.as_deref().unwrap_or("");
2894 for chunk in inline_text_chunks(text) {
2895 let is_space = chunk.chars().all(char::is_whitespace);
2896 if breaker.skips_leading_space(is_space) {
2897 continue;
2898 }
2899 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2900 if breaker.wraps_before(is_space, w) {
2901 breaker.finish_line();
2902 }
2903 if breaker.skips_overflowing_space(is_space, w) {
2904 continue;
2905 }
2906 breaker.push(w, ascent, descent);
2907 }
2908 continue;
2909 }
2910 _ => {}
2911 }
2912 let (w, ascent, descent) = inline_child_metrics(child);
2913 if breaker.wraps_before(false, w) {
2914 breaker.finish_line();
2915 }
2916 breaker.push(w, ascent, descent);
2917 }
2918 let measurement = breaker.finish();
2919 let w = measurement.width + node.padding.left + node.padding.right;
2920 let h = measurement.height + node.padding.top + node.padding.bottom;
2921 apply_min(node, w, h)
2922}
2923
2924fn inline_text_chunks(text: &str) -> Vec<&str> {
2925 let mut chunks = Vec::new();
2926 let mut start = 0;
2927 let mut last_space = None;
2928 for (i, ch) in text.char_indices() {
2929 let is_space = ch.is_whitespace();
2930 match last_space {
2931 None => last_space = Some(is_space),
2932 Some(prev) if prev != is_space => {
2933 chunks.push(&text[start..i]);
2934 start = i;
2935 last_space = Some(is_space);
2936 }
2937 _ => {}
2938 }
2939 }
2940 if start < text.len() {
2941 chunks.push(&text[start..]);
2942 }
2943 chunks
2944}
2945
2946fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2947 let layout = text_metrics::layout_text_with_line_height_and_family(
2948 text,
2949 child.font_size,
2950 child.line_height,
2951 child.font_family,
2952 child.font_weight,
2953 child.font_mono,
2954 child.text_tabular_numerals,
2955 TextWrap::NoWrap,
2956 None,
2957 );
2958 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2959}
2960
2961fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2962 match child.kind {
2963 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2964 Kind::Math => {
2965 if let Some(expr) = &child.math {
2966 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2967 (layout.width, layout.ascent, layout.descent)
2968 } else {
2969 (0.0, 0.0, 0.0)
2970 }
2971 }
2972 _ => (0.0, 0.0, 0.0),
2973 }
2974}
2975
2976fn concat_inline_text(children: &[El]) -> String {
2983 let mut s = String::new();
2984 for c in children {
2985 match c.kind {
2986 Kind::Text => {
2987 if let Some(t) = &c.text {
2988 s.push_str(t);
2989 }
2990 }
2991 Kind::HardBreak => s.push('\n'),
2992 _ => {}
2993 }
2994 }
2995 s
2996}
2997
2998fn inline_paragraph_size(node: &El) -> f32 {
3002 let mut size: f32 = node.font_size;
3003 for c in &node.children {
3004 if matches!(c.kind, Kind::Text) {
3005 size = size.max(c.font_size);
3006 }
3007 }
3008 size
3009}
3010
3011fn inline_paragraph_line_height(node: &El) -> f32 {
3012 let mut line_height: f32 = node.line_height;
3013 let mut max_size: f32 = node.font_size;
3014 for c in &node.children {
3015 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
3016 max_size = c.font_size;
3017 line_height = c.line_height;
3018 }
3019 }
3020 line_height
3021}
3022
3023#[cfg(test)]
3024mod tests {
3025 use super::*;
3026 use crate::state::UiState;
3027
3028 #[test]
3033 fn duplicate_sibling_keys_flagged_once() {
3034 let mut root = column([
3035 crate::widgets::text::text("a").key("dup"),
3036 crate::widgets::text::text("b").key("dup"),
3037 ]);
3038 let mut state = UiState::new();
3039 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3040
3041 assert_eq!(root.children[0].computed_id, root.children[1].computed_id);
3043 let id = root.children[0].computed_id.clone();
3044 assert!(state.layout.warned_duplicate_ids.contains(&id));
3046
3047 let n_before = state.layout.warned_duplicate_ids.len();
3049 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3050 assert_eq!(state.layout.warned_duplicate_ids.len(), n_before);
3051 }
3052
3053 #[test]
3055 fn distinct_sibling_keys_not_flagged() {
3056 let mut root = column([
3057 crate::widgets::text::text("a").key("one"),
3058 crate::widgets::text::text("b").key("two"),
3059 ]);
3060 let mut state = UiState::new();
3061 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3062 assert!(state.layout.warned_duplicate_ids.is_empty());
3063 }
3064
3065 #[test]
3070 fn align_center_shrinks_fill_child_to_intrinsic() {
3071 let mut root = column([crate::row([crate::widgets::text::text("hi")
3075 .width(Size::Fixed(40.0))
3076 .height(Size::Fixed(20.0))])])
3077 .align(Align::Center)
3078 .width(Size::Fixed(200.0))
3079 .height(Size::Fixed(100.0));
3080 let mut state = UiState::new();
3081 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3082 let row_rect = state.rect(&root.children[0].computed_id);
3083 assert!(
3086 (row_rect.x - 80.0).abs() < 0.5,
3087 "expected x≈80 (centered), got {}",
3088 row_rect.x
3089 );
3090 assert!(
3091 (row_rect.w - 40.0).abs() < 0.5,
3092 "expected w≈40 (shrunk to intrinsic), got {}",
3093 row_rect.w
3094 );
3095 }
3096
3097 #[test]
3101 fn max_clamped_fill_frees_space_to_sibling_fills() {
3102 let mut root = crate::row([
3103 El::new(Kind::Group).width(Size::Fill(1.0)).max_width(50.0),
3104 El::new(Kind::Group).width(Size::Fill(1.0)),
3105 ])
3106 .width(Size::Fixed(400.0))
3107 .height(Size::Fixed(100.0));
3108 let mut state = UiState::new();
3109 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3110 let a = state.rect(&root.children[0].computed_id);
3111 let b = state.rect(&root.children[1].computed_id);
3112 assert!((a.w - 50.0).abs() < 0.5, "capped fill w={}", a.w);
3113 assert!(
3114 (b.w - 350.0).abs() < 0.5,
3115 "sibling fill should absorb the freed 150px, got w={}",
3116 b.w
3117 );
3118 }
3119
3120 #[test]
3124 fn min_clamped_fill_steals_space_from_sibling_fills() {
3125 let mut root = crate::row([
3126 El::new(Kind::Group).width(Size::Fill(1.0)).min_width(300.0),
3127 El::new(Kind::Group).width(Size::Fill(1.0)),
3128 ])
3129 .width(Size::Fixed(400.0))
3130 .height(Size::Fixed(100.0));
3131 let mut state = UiState::new();
3132 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3133 let a = state.rect(&root.children[0].computed_id);
3134 let b = state.rect(&root.children[1].computed_id);
3135 assert!((a.w - 300.0).abs() < 0.5, "floored fill w={}", a.w);
3136 assert!(
3137 (b.w - 100.0).abs() < 0.5,
3138 "sibling fill should shrink to the remaining 100px, got w={}",
3139 b.w
3140 );
3141 }
3142
3143 #[test]
3147 fn justify_distributes_space_left_by_fully_capped_fills() {
3148 let mut root = crate::row([El::new(Kind::Group).width(Size::Fill(1.0)).max_width(100.0)])
3149 .justify(Justify::Center)
3150 .width(Size::Fixed(400.0))
3151 .height(Size::Fixed(100.0));
3152 let mut state = UiState::new();
3153 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3154 let a = state.rect(&root.children[0].computed_id);
3155 assert!((a.w - 100.0).abs() < 0.5, "capped fill w={}", a.w);
3156 assert!(
3157 (a.x - 150.0).abs() < 0.5,
3158 "capped fill should be centered (x≈150), got x={}",
3159 a.x
3160 );
3161 }
3162
3163 #[test]
3166 fn align_stretch_preserves_fill_stretch() {
3167 let mut root = column([crate::row([crate::widgets::text::text("hi")
3168 .width(Size::Fixed(40.0))
3169 .height(Size::Fixed(20.0))])])
3170 .align(Align::Stretch)
3171 .width(Size::Fixed(200.0))
3172 .height(Size::Fixed(100.0));
3173 let mut state = UiState::new();
3174 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3175 let row_rect = state.rect(&root.children[0].computed_id);
3176 assert!(
3177 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
3178 "expected stretched (x=0, w=200), got x={} w={}",
3179 row_rect.x,
3180 row_rect.w
3181 );
3182 }
3183
3184 #[test]
3191 fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
3192 let long = "The quick brown fox jumps over the lazy dog, then \
3193 does it again and again until the line is long \
3194 enough to wrap several times.";
3195 let mut root = column([column([column([crate::widgets::text::text(long)
3199 .wrap_text()
3200 .width(Size::Fixed(200.0))])
3201 .width(Size::Fixed(240.0))])
3202 .width(Size::Fill(1.0))]);
3203 let mut state = UiState::new();
3204 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3205 let card = state.rect(&root.children[0].computed_id);
3206 let text_rect = state.rect(&root.children[0].children[0].children[0].computed_id);
3207 assert!(
3208 text_rect.h > 25.0,
3209 "text should wrap to multiple lines at 200px, got h={}",
3210 text_rect.h
3211 );
3212 assert!(
3213 (card.h - text_rect.h).abs() < 0.5,
3214 "Hug card height {} must match wrapped text height {}",
3215 card.h,
3216 text_rect.h
3217 );
3218 }
3219
3220 #[test]
3223 fn justify_center_centers_hug_children() {
3224 let mut root = column([crate::widgets::text::text("hi")
3225 .width(Size::Fixed(40.0))
3226 .height(Size::Fixed(20.0))])
3227 .justify(Justify::Center)
3228 .height(Size::Fill(1.0));
3229 let mut state = UiState::new();
3230 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3231 let child_rect = state.rect(&root.children[0].computed_id);
3232 assert!(
3234 (child_rect.y - 40.0).abs() < 0.5,
3235 "expected y≈40, got {}",
3236 child_rect.y
3237 );
3238 }
3239
3240 #[test]
3241 fn justify_end_pushes_to_bottom() {
3242 let mut root = column([crate::widgets::text::text("hi")
3243 .width(Size::Fixed(40.0))
3244 .height(Size::Fixed(20.0))])
3245 .justify(Justify::End)
3246 .height(Size::Fill(1.0));
3247 let mut state = UiState::new();
3248 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3249 let child_rect = state.rect(&root.children[0].computed_id);
3250 assert!(
3251 (child_rect.y - 80.0).abs() < 0.5,
3252 "expected y≈80, got {}",
3253 child_rect.y
3254 );
3255 }
3256
3257 #[test]
3261 fn justify_space_between_distributes_evenly() {
3262 let row_child = || {
3263 crate::widgets::text::text("x")
3264 .width(Size::Fixed(20.0))
3265 .height(Size::Fixed(20.0))
3266 };
3267 let mut root = column([row_child(), row_child(), row_child()])
3268 .justify(Justify::SpaceBetween)
3269 .height(Size::Fixed(200.0));
3270 let mut state = UiState::new();
3271 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
3272 let y0 = state.rect(&root.children[0].computed_id).y;
3275 let y1 = state.rect(&root.children[1].computed_id).y;
3276 let y2 = state.rect(&root.children[2].computed_id).y;
3277 assert!(
3278 y0.abs() < 0.5,
3279 "first child should be flush at y=0, got {y0}"
3280 );
3281 assert!(
3282 (y1 - 90.0).abs() < 0.5,
3283 "middle child should be at y≈90, got {y1}"
3284 );
3285 assert!(
3286 (y2 - 180.0).abs() < 0.5,
3287 "last child should be flush at y≈180, got {y2}"
3288 );
3289 }
3290
3291 #[test]
3295 fn fill_weight_distributes_proportionally() {
3296 let big = crate::widgets::text::text("big")
3297 .width(Size::Fixed(40.0))
3298 .height(Size::Fill(2.0));
3299 let small = crate::widgets::text::text("small")
3300 .width(Size::Fixed(40.0))
3301 .height(Size::Fill(1.0));
3302 let mut root = column([big, small]).height(Size::Fixed(300.0));
3303 let mut state = UiState::new();
3304 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
3305 let big_h = state.rect(&root.children[0].computed_id).h;
3307 let small_h = state.rect(&root.children[1].computed_id).h;
3308 assert!(
3309 (big_h - 200.0).abs() < 0.5,
3310 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
3311 );
3312 assert!(
3313 (small_h - 100.0).abs() < 0.5,
3314 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
3315 );
3316 }
3317
3318 #[test]
3322 fn padding_on_hug_includes_in_intrinsic() {
3323 let root = column([crate::widgets::text::text("x")
3324 .width(Size::Fixed(40.0))
3325 .height(Size::Fixed(40.0))])
3326 .padding(Sides::all(20.0));
3327 let (w, h) = intrinsic(&root);
3328 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
3330 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
3331 }
3332
3333 #[test]
3337 fn align_end_pins_to_cross_axis_far_edge() {
3338 let mut root = crate::row([crate::widgets::text::text("hi")
3339 .width(Size::Fixed(40.0))
3340 .height(Size::Fixed(20.0))])
3341 .align(Align::End)
3342 .width(Size::Fixed(200.0))
3343 .height(Size::Fixed(100.0));
3344 let mut state = UiState::new();
3345 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3346 let child_rect = state.rect(&root.children[0].computed_id);
3347 assert!(
3349 (child_rect.y - 80.0).abs() < 0.5,
3350 "expected y≈80 (pinned to bottom), got {}",
3351 child_rect.y
3352 );
3353 }
3354
3355 #[test]
3356 fn overlay_can_center_hug_child() {
3357 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
3358 .width(Size::Fixed(200.0))
3359 .height(Size::Hug)])
3360 .align(Align::Center)
3361 .justify(Justify::Center);
3362 let mut state = UiState::new();
3363 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
3364 let child_rect = state.rect(&root.children[0].computed_id);
3365 assert!(
3366 (child_rect.x - 200.0).abs() < 0.5,
3367 "expected x≈200, got {}",
3368 child_rect.x
3369 );
3370 assert!(
3371 child_rect.y > 100.0 && child_rect.y < 200.0,
3372 "expected centered y, got {}",
3373 child_rect.y
3374 );
3375 }
3376
3377 #[test]
3378 fn scroll_offset_translates_children_and_clamps_to_content() {
3379 let mut root = scroll(
3383 (0..6)
3384 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3385 )
3386 .key("list")
3387 .gap(12.0)
3388 .height(Size::Fixed(200.0));
3389 let mut state = UiState::new();
3390 assign_ids(&mut root);
3391 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
3392
3393 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3394
3395 let stored = state
3397 .scroll
3398 .offsets
3399 .get(&root.computed_id)
3400 .copied()
3401 .unwrap_or(0.0);
3402 assert!(
3403 (stored - 80.0).abs() < 0.01,
3404 "offset clamped unexpectedly: {stored}"
3405 );
3406 let c0 = state.rect(&root.children[0].computed_id);
3408 assert!(
3409 (c0.y - (-80.0)).abs() < 0.01,
3410 "child 0 y = {} (expected -80)",
3411 c0.y
3412 );
3413 state
3415 .scroll
3416 .offsets
3417 .insert(root.computed_id.clone(), 9999.0);
3418 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3419 let stored = state
3420 .scroll
3421 .offsets
3422 .get(&root.computed_id)
3423 .copied()
3424 .unwrap_or(0.0);
3425 assert!(
3426 (stored - 160.0).abs() < 0.01,
3427 "overshoot clamped to {stored}"
3428 );
3429 let mut tiny =
3431 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
3432 .height(Size::Fixed(200.0));
3433 let mut tiny_state = UiState::new();
3434 assign_ids(&mut tiny);
3435 tiny_state
3436 .scroll
3437 .offsets
3438 .insert(tiny.computed_id.clone(), 50.0);
3439 layout(
3440 &mut tiny,
3441 &mut tiny_state,
3442 Rect::new(0.0, 0.0, 300.0, 200.0),
3443 );
3444 assert_eq!(
3445 tiny_state
3446 .scroll
3447 .offsets
3448 .get(&tiny.computed_id)
3449 .copied()
3450 .unwrap_or(0.0),
3451 0.0
3452 );
3453 }
3454
3455 #[test]
3456 fn scroll_layout_prunes_far_offscreen_descendants() {
3457 let far = column([crate::widgets::text::text("far row body").key("far-text")])
3458 .height(Size::Fixed(40.0));
3459 let mut root = scroll([
3460 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
3461 crate::tree::spacer().height(Size::Fixed(400.0)),
3462 far,
3463 ])
3464 .key("list")
3465 .height(Size::Fixed(80.0));
3466 let mut state = UiState::new();
3467 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
3468 let stats = take_prune_stats();
3469
3470 assert!(
3471 stats.subtrees >= 1,
3472 "expected at least one far scroll child to be pruned, got {stats:?}"
3473 );
3474 assert!(
3475 stats.nodes >= 1,
3476 "expected pruned descendants to be zeroed, got {stats:?}"
3477 );
3478 let far_text = state
3479 .rect_of_key("far-text")
3480 .expect("far text keeps a zero rect while pruned");
3481 assert_eq!(far_text.w, 0.0);
3482 assert_eq!(far_text.h, 0.0);
3483 }
3484
3485 #[test]
3486 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
3487 let make_root = || {
3488 let paragraph_text = "Variable width text wraps into a different number of lines when \
3489 the viewport narrows, which used to make a plain scroll box lose \
3490 the item the user was reading.";
3491 scroll([column((0..30).map(|i| {
3492 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
3493 .key(format!("paragraph-{i}"))
3494 }))
3495 .gap(8.0)])
3496 .key("article")
3497 .height(Size::Fixed(180.0))
3498 };
3499
3500 let mut root = make_root();
3501 let mut state = UiState::new();
3502 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3503
3504 state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
3505 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3506
3507 let anchor = state
3508 .scroll
3509 .scroll_anchors
3510 .get(&root.computed_id)
3511 .cloned()
3512 .expect("plain scroll should store a visible descendant anchor");
3513 let before_rect = state.rect(&anchor.node_id);
3514 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
3515 let before_offset = state.scroll_offset(&root.computed_id);
3516
3517 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
3518
3519 let after_rect = state.rect(&anchor.node_id);
3520 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
3521 let after_offset = state.scroll_offset(&root.computed_id);
3522 assert!(
3523 (after_anchor_y - before_anchor_y).abs() < 0.5,
3524 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
3525 );
3526 assert!(
3527 (after_offset - before_offset).abs() > 20.0,
3528 "offset should absorb height changes above the anchor"
3529 );
3530 }
3531
3532 #[test]
3533 fn scrollbar_thumb_size_and_position_track_overflow() {
3534 let mut root = scroll(
3537 (0..6)
3538 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3539 )
3540 .gap(12.0)
3541 .height(Size::Fixed(200.0));
3542 let mut state = UiState::new();
3543 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3544
3545 let metrics = state
3546 .scroll
3547 .metrics
3548 .get(&root.computed_id)
3549 .copied()
3550 .expect("scrollable should have metrics");
3551 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
3552 assert!((metrics.content_h - 360.0).abs() < 0.01);
3553 assert!((metrics.max_offset - 160.0).abs() < 0.01);
3554
3555 let thumb = state
3556 .scroll
3557 .thumb_rects
3558 .get(&root.computed_id)
3559 .copied()
3560 .expect("scrollable with scrollbar() and overflow gets a thumb");
3561 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
3563 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
3564 assert!(thumb.y.abs() < 0.01);
3566 assert!(
3568 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
3569 "thumb anchored at {} (expected {})",
3570 thumb.x,
3571 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
3572 );
3573
3574 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
3576 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3577 let thumb = state
3578 .scroll
3579 .thumb_rects
3580 .get(&root.computed_id)
3581 .copied()
3582 .unwrap();
3583 let track_remaining = 200.0 - thumb.h;
3584 let expected_y = track_remaining * (80.0 / 160.0);
3585 assert!(
3586 (thumb.y - expected_y).abs() < 0.5,
3587 "thumb at half-scroll y = {} (expected {expected_y})",
3588 thumb.y,
3589 );
3590 }
3591
3592 #[test]
3593 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
3594 let mut root = scroll(
3598 (0..6)
3599 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3600 )
3601 .gap(12.0)
3602 .height(Size::Fixed(200.0));
3603 let mut state = UiState::new();
3604 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3605
3606 let thumb = state
3607 .scroll
3608 .thumb_rects
3609 .get(&root.computed_id)
3610 .copied()
3611 .unwrap();
3612 let track = state
3613 .scroll
3614 .thumb_tracks
3615 .get(&root.computed_id)
3616 .copied()
3617 .unwrap();
3618 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
3620 assert!(
3621 (track.right() - thumb.right()).abs() < 0.01,
3622 "track and thumb must share the right edge",
3623 );
3624 assert!(
3627 (track.h - 200.0).abs() < 0.01,
3628 "track height = {} (expected 200)",
3629 track.h,
3630 );
3631 }
3632
3633 #[test]
3634 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
3635 let mut suppressed = scroll(
3637 (0..6)
3638 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3639 )
3640 .no_scrollbar()
3641 .height(Size::Fixed(200.0));
3642 let mut state = UiState::new();
3643 layout(
3644 &mut suppressed,
3645 &mut state,
3646 Rect::new(0.0, 0.0, 300.0, 200.0),
3647 );
3648 assert!(
3649 !state
3650 .scroll
3651 .thumb_rects
3652 .contains_key(&suppressed.computed_id)
3653 );
3654
3655 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
3657 .height(Size::Fixed(200.0));
3658 let mut tiny_state = UiState::new();
3659 layout(
3660 &mut tiny,
3661 &mut tiny_state,
3662 Rect::new(0.0, 0.0, 300.0, 200.0),
3663 );
3664 assert!(
3665 !tiny_state
3666 .scroll
3667 .thumb_rects
3668 .contains_key(&tiny.computed_id)
3669 );
3670 }
3671
3672 #[test]
3673 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
3674 let make_root = || {
3675 scroll([
3676 crate::tree::spacer().height(Size::Fixed(80.0)),
3677 scroll((0..6).map(|i| {
3678 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
3679 }))
3680 .key("inner")
3681 .height(Size::Fixed(120.0)),
3682 crate::tree::spacer().height(Size::Fixed(260.0)),
3683 ])
3684 .key("outer")
3685 .height(Size::Fixed(220.0))
3686 };
3687
3688 let mut root = make_root();
3689 let mut state = UiState::new();
3690 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3691 let inner = root
3692 .children
3693 .iter()
3694 .find(|child| child.key.as_deref() == Some("inner"))
3695 .expect("inner scroll");
3696 let inner_id = inner.computed_id.clone();
3697 let inner_rect = state.rect(&inner_id);
3698 let thumb = state
3699 .scroll
3700 .thumb_rects
3701 .get(&inner_id)
3702 .copied()
3703 .expect("inner scroll should have a thumb");
3704 let track = state
3705 .scroll
3706 .thumb_tracks
3707 .get(&inner_id)
3708 .copied()
3709 .expect("inner scroll should have a track");
3710 let thumb_rel_y = thumb.y - inner_rect.y;
3711 let track_rel_y = track.y - inner_rect.y;
3712
3713 state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3714 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3715 let inner_rect_after = state.rect(&inner_id);
3716 let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3717 let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3718
3719 assert!(
3720 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3721 "outer scroll should shift the inner viewport"
3722 );
3723 assert!(
3724 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3725 "inner thumb should stay fixed relative to its viewport"
3726 );
3727 assert!(
3728 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3729 "inner track should stay fixed relative to its viewport"
3730 );
3731 }
3732
3733 #[test]
3734 fn layout_override_places_children_at_returned_rects() {
3735 let mut root = column((0..3).map(|i| {
3737 crate::widgets::text::text(format!("dot {i}"))
3738 .width(Size::Fixed(20.0))
3739 .height(Size::Fixed(20.0))
3740 }))
3741 .width(Size::Fixed(200.0))
3742 .height(Size::Fixed(200.0))
3743 .layout(|ctx| {
3744 ctx.children
3745 .iter()
3746 .enumerate()
3747 .map(|(i, _)| {
3748 let off = i as f32 * 30.0;
3749 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3750 })
3751 .collect()
3752 });
3753 let mut state = UiState::new();
3754 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3755 let r0 = state.rect(&root.children[0].computed_id);
3756 let r1 = state.rect(&root.children[1].computed_id);
3757 let r2 = state.rect(&root.children[2].computed_id);
3758 assert_eq!((r0.x, r0.y), (0.0, 0.0));
3759 assert_eq!((r1.x, r1.y), (30.0, 30.0));
3760 assert_eq!((r2.x, r2.y), (60.0, 60.0));
3761 }
3762
3763 #[test]
3764 fn layout_override_rect_of_key_resolves_earlier_sibling() {
3765 use crate::tree::stack;
3771 let trigger_x = 40.0;
3772 let trigger_y = 20.0;
3773 let trigger_w = 60.0;
3774 let trigger_h = 30.0;
3775 let mut root = stack([
3776 crate::widgets::button::button("Open")
3778 .key("trig")
3779 .width(Size::Fixed(trigger_w))
3780 .height(Size::Fixed(trigger_h)),
3781 stack([crate::widgets::text::text("popover")
3784 .width(Size::Fixed(80.0))
3785 .height(Size::Fixed(20.0))])
3786 .width(Size::Fill(1.0))
3787 .height(Size::Fill(1.0))
3788 .layout(|ctx| {
3789 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3790 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3791 }),
3792 ])
3793 .padding(Sides::xy(trigger_x, trigger_y));
3794 let mut state = UiState::new();
3795 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3796
3797 let popover_layer = &root.children[1];
3798 let panel_id = &popover_layer.children[0].computed_id;
3799 let panel_rect = state.rect(panel_id);
3800 assert!(
3803 (panel_rect.x - trigger_x).abs() < 0.01,
3804 "popover x = {} (expected {trigger_x})",
3805 panel_rect.x,
3806 );
3807 assert!(
3808 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3809 "popover y = {} (expected {})",
3810 panel_rect.y,
3811 trigger_y + trigger_h + 4.0,
3812 );
3813 }
3814
3815 #[test]
3816 fn layout_override_rect_of_key_returns_none_for_missing_key() {
3817 let mut root = column([crate::widgets::text::text("inner")
3818 .width(Size::Fixed(40.0))
3819 .height(Size::Fixed(20.0))])
3820 .width(Size::Fixed(200.0))
3821 .height(Size::Fixed(200.0))
3822 .layout(|ctx| {
3823 assert!((ctx.rect_of_key)("nope").is_none());
3824 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3825 });
3826 let mut state = UiState::new();
3827 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3828 }
3829
3830 #[test]
3831 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3832 use crate::tree::stack;
3838 let mut root = stack([
3839 stack([crate::widgets::text::text("panel")
3840 .width(Size::Fixed(40.0))
3841 .height(Size::Fixed(20.0))])
3842 .width(Size::Fill(1.0))
3843 .height(Size::Fill(1.0))
3844 .layout(|ctx| {
3845 assert!(
3846 (ctx.rect_of_key)("later").is_none(),
3847 "later sibling's rect must not be available yet"
3848 );
3849 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3850 }),
3851 crate::widgets::button::button("after").key("later"),
3852 ]);
3853 let mut state = UiState::new();
3854 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3855 }
3856
3857 #[test]
3858 fn layout_override_measure_returns_intrinsic() {
3859 let mut root = column([crate::widgets::text::text("hi")
3861 .width(Size::Fixed(40.0))
3862 .height(Size::Fixed(20.0))])
3863 .width(Size::Fixed(200.0))
3864 .height(Size::Fixed(200.0))
3865 .layout(|ctx| {
3866 let (w, h) = (ctx.measure)(&ctx.children[0]);
3867 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3868 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3869 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3870 });
3871 let mut state = UiState::new();
3872 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3873 let r = state.rect(&root.children[0].computed_id);
3874 assert_eq!((r.w, r.h), (40.0, 20.0));
3875 }
3876
3877 #[test]
3878 #[should_panic(expected = "returned 1 rects for 2 children")]
3879 fn layout_override_length_mismatch_panics() {
3880 let mut root = column([
3881 crate::widgets::text::text("a")
3882 .width(Size::Fixed(10.0))
3883 .height(Size::Fixed(10.0)),
3884 crate::widgets::text::text("b")
3885 .width(Size::Fixed(10.0))
3886 .height(Size::Fixed(10.0)),
3887 ])
3888 .width(Size::Fixed(200.0))
3889 .height(Size::Fixed(200.0))
3890 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3891 let mut state = UiState::new();
3892 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3893 }
3894
3895 #[test]
3896 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3897 fn layout_override_hug_panics() {
3898 let mut root = column([column([crate::widgets::text::text("c")])
3902 .width(Size::Hug)
3903 .height(Size::Fixed(200.0))
3904 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3905 .width(Size::Fixed(200.0))
3906 .height(Size::Fixed(200.0));
3907 let mut state = UiState::new();
3908 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3909 }
3910
3911 #[test]
3912 fn fit_contain_letterboxes_and_centers() {
3913 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted");
3916 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
3917 .width(Size::Fixed(400.0))
3918 .height(Size::Fixed(400.0));
3919 let mut state = UiState::new();
3920 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3921
3922 let r = state.rect_of_key("fitted").expect("fitted rect");
3923 assert_eq!((r.w, r.h), (400.0, 225.0));
3924 assert_eq!(r.x, 0.0);
3925 assert!((r.y - 87.5).abs() < 0.01, "centered: y = {}", r.y);
3926
3927 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted2");
3929 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
3930 .width(Size::Fixed(400.0))
3931 .height(Size::Fixed(100.0));
3932 let mut state = UiState::new();
3933 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3934 let r = state.rect_of_key("fitted2").expect("fitted2 rect");
3935 assert!((r.w - 100.0 * 16.0 / 9.0).abs() < 0.01);
3936 assert_eq!(r.h, 100.0);
3937 assert!((r.x - (400.0 - r.w) / 2.0).abs() < 0.01);
3938 }
3939
3940 #[test]
3941 fn fit_cover_overflows_the_slack_axis_and_clips() {
3942 let child = crate::tree::column([crate::widgets::text::text("c")]).key("covered");
3946 let mut root = crate::tree::fit_cover(child, 1.0)
3947 .width(Size::Fixed(400.0))
3948 .height(Size::Fixed(200.0));
3949 assert!(root.clip, "fit_cover must clip its overflow");
3950 let mut state = UiState::new();
3951 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
3952 let r = state.rect_of_key("covered").expect("covered rect");
3953 assert_eq!((r.w, r.h), (400.0, 400.0));
3954 assert!(
3955 (r.y - -100.0).abs() < 0.01,
3956 "centered overflow: y = {}",
3957 r.y
3958 );
3959 }
3960
3961 #[test]
3962 fn fit_contain_intrinsic_uses_the_child_measure() {
3963 let child = crate::tree::column::<Vec<El>, El>(vec![])
3965 .width(Size::Fixed(100.0))
3966 .height(Size::Fixed(50.0))
3967 .key("intrinsic");
3968 let mut root = crate::tree::fit_contain_intrinsic(child)
3969 .width(Size::Fixed(400.0))
3970 .height(Size::Fixed(400.0));
3971 let mut state = UiState::new();
3972 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
3973 let r = state.rect_of_key("intrinsic").expect("intrinsic rect");
3974 assert_eq!((r.w, r.h), (400.0, 200.0));
3975 }
3976
3977 #[test]
3978 fn virtual_list_realizes_only_visible_rows() {
3979 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3983 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3984 });
3985 let mut state = UiState::new();
3986 assign_ids(&mut root);
3987 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3988 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3989
3990 assert_eq!(
3991 root.children.len(),
3992 5,
3993 "expected 5 realized rows, got {}",
3994 root.children.len()
3995 );
3996 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3998 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3999 let r0 = state.rect(&root.children[0].computed_id);
4001 assert!(
4002 (r0.y - (-20.0)).abs() < 0.5,
4003 "row 2 expected y≈-20, got {}",
4004 r0.y
4005 );
4006 }
4007
4008 #[test]
4009 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
4010 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
4011 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4012 })
4013 .gap(10.0);
4014 let mut state = UiState::new();
4015 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
4016
4017 assert_eq!(
4018 root.children.len(),
4019 3,
4020 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
4021 );
4022 let row_1 = root
4023 .children
4024 .iter()
4025 .find(|c| c.key.as_deref() == Some("row-1"))
4026 .expect("row 1 should be realized");
4027 assert!(
4028 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
4029 "gap should place row 1 at y=50"
4030 );
4031 let metrics = state
4032 .scroll
4033 .metrics
4034 .get(&root.computed_id)
4035 .expect("virtual list writes scroll metrics");
4036 assert!(
4037 (metrics.content_h - 490.0).abs() < 0.5,
4038 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
4039 metrics.content_h
4040 );
4041 }
4042
4043 #[test]
4044 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
4045 let make_root = || {
4046 crate::tree::virtual_list(50, 50.0, |i| {
4047 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4048 })
4049 };
4050
4051 let mut state = UiState::new();
4052 let mut root_a = make_root();
4053 assign_ids(&mut root_a);
4054 state
4056 .scroll
4057 .offsets
4058 .insert(root_a.computed_id.clone(), 250.0);
4059 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4060 let id_at_offset_a = root_a
4061 .children
4062 .iter()
4063 .find(|c| c.key.as_deref() == Some("row-5"))
4064 .unwrap()
4065 .computed_id
4066 .clone();
4067
4068 let mut root_b = make_root();
4070 assign_ids(&mut root_b);
4071 state
4072 .scroll
4073 .offsets
4074 .insert(root_b.computed_id.clone(), 200.0);
4075 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4076 let id_at_offset_b = root_b
4077 .children
4078 .iter()
4079 .find(|c| c.key.as_deref() == Some("row-5"))
4080 .unwrap()
4081 .computed_id
4082 .clone();
4083
4084 assert_eq!(
4085 id_at_offset_a, id_at_offset_b,
4086 "row-5's computed_id changed when scroll offset moved"
4087 );
4088 }
4089
4090 #[test]
4091 fn virtual_list_clamps_overshoot_offset() {
4092 let mut root =
4094 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
4095 let mut state = UiState::new();
4096 assign_ids(&mut root);
4097 state
4098 .scroll
4099 .offsets
4100 .insert(root.computed_id.clone(), 9999.0);
4101 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4102 let stored = state
4103 .scroll
4104 .offsets
4105 .get(&root.computed_id)
4106 .copied()
4107 .unwrap_or(0.0);
4108 assert!(
4109 (stored - 300.0).abs() < 0.01,
4110 "expected clamp to 300, got {stored}"
4111 );
4112 }
4113
4114 #[test]
4115 fn virtual_list_empty_count_realizes_no_children() {
4116 let mut root =
4117 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
4118 let mut state = UiState::new();
4119 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4120 assert_eq!(root.children.len(), 0);
4121 }
4122
4123 #[test]
4124 #[should_panic(expected = "row_height > 0.0")]
4125 fn virtual_list_zero_row_height_panics() {
4126 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
4127 }
4128
4129 #[test]
4130 #[should_panic(expected = "Size::Hug would defeat virtualization")]
4131 fn virtual_list_hug_panics() {
4132 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
4133 crate::widgets::text::text(format!("r{i}"))
4134 })
4135 .height(Size::Hug)])
4136 .width(Size::Fixed(300.0))
4137 .height(Size::Fixed(200.0));
4138 let mut state = UiState::new();
4139 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4140 }
4141
4142 #[test]
4143 fn grid_packs_rows_and_aligns_the_partial_tail() {
4144 let cell = |i: usize| {
4148 crate::tree::column::<Vec<El>, El>(vec![])
4149 .key(format!("cell-{i}"))
4150 .height(Size::Fixed(50.0))
4151 };
4152 let mut root = crate::tree::grid(2, 10.0, (0..3).map(cell))
4153 .width(Size::Fixed(210.0))
4154 .height(Size::Fixed(300.0));
4155 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
4156 let mut state = UiState::new();
4157 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 210.0, 300.0));
4158
4159 let r0 = state.rect_of_key("cell-0").unwrap();
4160 let r1 = state.rect_of_key("cell-1").unwrap();
4161 let r2 = state.rect_of_key("cell-2").unwrap();
4162 assert_eq!((r0.x, r0.w), (0.0, 100.0));
4163 assert_eq!((r1.x, r1.w), (110.0, 100.0));
4164 assert_eq!((r2.x, r2.w), (0.0, 100.0));
4166 assert_eq!(r2.y, 60.0);
4167 }
4168
4169 #[test]
4170 fn virtual_grid_realizes_rows_of_packed_cells() {
4171 let mut root = crate::tree::virtual_grid(10, 3, 50.0, 0.0, |i| {
4174 crate::widgets::text::text(format!("item {i}")).key(format!("item-{i}"))
4175 })
4176 .key("vgrid");
4177 let mut state = UiState::new();
4178 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
4179
4180 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
4181 let rect_of = |state: &UiState, row: usize, col: usize| {
4186 state.rect(&root.children[row].children[col].computed_id)
4187 };
4188 let i0 = rect_of(&state, 0, 0);
4190 let i2 = rect_of(&state, 0, 2);
4191 assert_eq!(i0.w, 100.0);
4192 assert_eq!(i2.x, 200.0);
4193 let i3 = rect_of(&state, 1, 0);
4195 assert_eq!((i3.x, i3.y), (0.0, 50.0));
4196 assert!(state.visible_range("vgrid").is_some());
4198 assert_eq!(root.children[0].children[0].key.as_deref(), Some("item-0"));
4200 }
4201
4202 #[test]
4203 fn visible_range_tracks_realized_rows() {
4204 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
4208 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4209 })
4210 .key("list");
4211 let mut state = UiState::new();
4212 assign_ids(&mut root);
4213 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
4214 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4215
4216 assert_eq!(state.visible_range("list"), Some(2..7));
4217 assert_eq!(state.visible_range("not-a-list"), None);
4218
4219 let mut dyn_root = crate::tree::virtual_list_dyn(
4222 20,
4223 50.0,
4224 |i| format!("row-{i}"),
4225 |i| {
4226 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
4227 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4228 .key(format!("row-{i}"))
4229 .height(Size::Fixed(h))
4230 },
4231 )
4232 .key("dyn-list");
4233 let mut dyn_state = UiState::new();
4234 layout(
4235 &mut dyn_root,
4236 &mut dyn_state,
4237 Rect::new(0.0, 0.0, 300.0, 200.0),
4238 );
4239 assert_eq!(dyn_state.visible_range("dyn-list"), Some(0..4));
4240
4241 let mut empty =
4243 crate::tree::virtual_list(0, 50.0, |_| crate::widgets::text::text("never")).key("list");
4244 layout(&mut empty, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4245 assert_eq!(state.visible_range("list"), None);
4246 }
4247
4248 #[test]
4249 fn virtual_list_dyn_respects_per_row_fixed_heights() {
4250 let mut root = crate::tree::virtual_list_dyn(
4254 20,
4255 50.0,
4256 |i| format!("row-{i}"),
4257 |i| {
4258 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
4259 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4260 .key(format!("row-{i}"))
4261 .height(Size::Fixed(h))
4262 },
4263 );
4264 let mut state = UiState::new();
4265 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4266
4267 assert_eq!(
4268 root.children.len(),
4269 4,
4270 "expected 4 realized rows, got {}",
4271 root.children.len()
4272 );
4273 let ys: Vec<f32> = root
4275 .children
4276 .iter()
4277 .map(|c| state.rect(&c.computed_id).y)
4278 .collect();
4279 assert!(
4280 (ys[0] - 0.0).abs() < 0.5,
4281 "row 0 expected y≈0, got {}",
4282 ys[0]
4283 );
4284 assert!(
4285 (ys[1] - 40.0).abs() < 0.5,
4286 "row 1 expected y≈40, got {}",
4287 ys[1]
4288 );
4289 assert!(
4290 (ys[2] - 120.0).abs() < 0.5,
4291 "row 2 expected y≈120, got {}",
4292 ys[2]
4293 );
4294 assert!(
4295 (ys[3] - 160.0).abs() < 0.5,
4296 "row 3 expected y≈160, got {}",
4297 ys[3]
4298 );
4299 }
4300
4301 #[test]
4302 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
4303 let mut root = crate::tree::virtual_list_dyn(
4304 10,
4305 40.0,
4306 |i| format!("row-{i}"),
4307 |i| {
4308 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
4309 .key(format!("row-{i}"))
4310 .height(Size::Fixed(40.0))
4311 },
4312 )
4313 .gap(10.0);
4314 let mut state = UiState::new();
4315 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
4316
4317 assert_eq!(
4318 root.children.len(),
4319 3,
4320 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
4321 );
4322 let row_1 = root
4323 .children
4324 .iter()
4325 .find(|c| c.key.as_deref() == Some("row-1"))
4326 .expect("row 1 should be realized");
4327 assert!(
4328 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
4329 "gap should place row 1 at y=50"
4330 );
4331 let metrics = state
4332 .scroll
4333 .metrics
4334 .get(&root.computed_id)
4335 .expect("virtual list writes scroll metrics");
4336 assert!(
4337 (metrics.content_h - 490.0).abs() < 0.5,
4338 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
4339 metrics.content_h
4340 );
4341 }
4342
4343 #[test]
4344 fn virtual_list_dyn_caches_measured_heights() {
4345 let mut root = crate::tree::virtual_list_dyn(
4349 50,
4350 50.0,
4351 |i| format!("row-{i}"),
4352 |i| {
4353 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4354 .key(format!("row-{i}"))
4355 .height(Size::Fixed(30.0))
4356 },
4357 );
4358 let mut state = UiState::new();
4359 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4360
4361 let measured = state
4362 .scroll
4363 .measured_row_heights
4364 .get(&root.computed_id)
4365 .expect("dynamic virtual list should populate the height cache");
4366 assert!(
4370 measured.len() >= 6,
4371 "expected ≥ 6 cached row heights, got {}",
4372 measured.len()
4373 );
4374 for by_width in measured.values() {
4375 let h = by_width
4376 .get(&300)
4377 .copied()
4378 .expect("measurement should be keyed at the 300px width bucket");
4379 assert!(
4380 (h - 30.0).abs() < 0.5,
4381 "expected cached height ≈ 30, got {h}"
4382 );
4383 }
4384 }
4385
4386 #[test]
4387 fn virtual_list_dyn_measure_pass_seeds_intrinsic_cache_for_layout_pass() {
4388 let mut root = crate::tree::virtual_list_dyn(
4394 50,
4395 20.0,
4396 |i| format!("row-{i}"),
4397 |i| {
4398 crate::tree::column([crate::widgets::text::text(format!("row body {i}"))])
4399 .key(format!("row-{i}"))
4400 .height(Size::Hug)
4401 },
4402 );
4403 let mut state = UiState::new();
4404 let _ = take_intrinsic_cache_stats();
4405 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4406 let stats = take_intrinsic_cache_stats();
4407 let realized = root.children.len() as u64;
4408 assert!(realized > 0, "test requires realized rows");
4409 assert!(
4410 stats.hits >= realized,
4411 "layout pass should re-measure realized rows from the intrinsic cache \
4412 (hits {} < realized {realized})",
4413 stats.hits
4414 );
4415 }
4416
4417 #[test]
4418 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
4419 let make_root = || {
4420 crate::tree::virtual_list_dyn(
4421 100,
4422 40.0,
4423 |i| format!("row-{i}"),
4424 |i| {
4425 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4426 .key(format!("row-{i}"))
4427 .height(Size::Fixed(40.0))
4428 },
4429 )
4430 };
4431 let mut root = make_root();
4432 let mut state = UiState::new();
4433 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4434
4435 state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
4436 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4437
4438 let anchor = state
4439 .scroll
4440 .virtual_anchors
4441 .get(&root.computed_id)
4442 .cloned()
4443 .expect("dynamic list should store a visible anchor");
4444 let before_y = root
4445 .children
4446 .iter()
4447 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
4448 .map(|child| state.rect(&child.computed_id).y)
4449 .expect("anchor row should be realized");
4450 let before_offset = state.scroll_offset(&root.computed_id);
4451
4452 state
4453 .scroll
4454 .measured_row_heights
4455 .entry(root.computed_id.clone())
4456 .or_default()
4457 .entry("row-0".to_string())
4458 .or_default()
4459 .insert(300, 120.0);
4460
4461 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4462 let after_y = root
4463 .children
4464 .iter()
4465 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
4466 .map(|child| state.rect(&child.computed_id).y)
4467 .expect("anchor row should remain realized");
4468 let after_offset = state.scroll_offset(&root.computed_id);
4469
4470 assert!(
4471 (after_y - before_y).abs() < 0.5,
4472 "anchor row should stay at y={before_y}, got {after_y}"
4473 );
4474 assert!(
4475 (after_offset - (before_offset + 80.0)).abs() < 0.5,
4476 "offset should absorb the 80px measurement delta above anchor"
4477 );
4478 }
4479
4480 #[test]
4481 fn virtual_list_dyn_height_cache_is_width_bucketed() {
4482 let mut root = crate::tree::virtual_list_dyn(
4483 20,
4484 50.0,
4485 |i| format!("row-{i}"),
4486 |i| {
4487 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4488 .key(format!("row-{i}"))
4489 .height(Size::Fixed(30.0))
4490 },
4491 );
4492 let mut state = UiState::new();
4493 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4494 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
4495
4496 let row_0 = state
4497 .scroll
4498 .measured_row_heights
4499 .get(&root.computed_id)
4500 .and_then(|m| m.get("row-0"))
4501 .expect("row 0 should be measured");
4502 assert!(
4503 row_0.contains_key(&300) && row_0.contains_key(&240),
4504 "expected width buckets 300 and 240, got {:?}",
4505 row_0.keys().collect::<Vec<_>>()
4506 );
4507 }
4508
4509 #[test]
4510 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
4511 let make_root = || {
4516 crate::tree::virtual_list_dyn(
4517 20,
4518 50.0,
4519 |i| format!("row-{i}"),
4520 |i| {
4521 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4522 .key(format!("row-{i}"))
4523 .height(Size::Fixed(30.0))
4524 },
4525 )
4526 };
4527 let mut state = UiState::new();
4528 let mut root = make_root();
4529 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4530
4531 state
4532 .scroll
4533 .offsets
4534 .insert(root.computed_id.clone(), 9999.0);
4535 let mut root2 = make_root();
4536 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4537
4538 let measured = state
4539 .scroll
4540 .measured_row_heights
4541 .get(&root2.computed_id)
4542 .expect("dynamic virtual list should populate the height cache");
4543 let measured_sum = measured
4544 .values()
4545 .filter_map(|by_width| by_width.get(&300))
4546 .sum::<f32>();
4547 let measured_count = measured
4548 .values()
4549 .filter(|by_width| by_width.contains_key(&300))
4550 .count();
4551 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
4552 let expected_max_offset = expected_total - 200.0;
4553
4554 let stored = state
4555 .scroll
4556 .offsets
4557 .get(&root2.computed_id)
4558 .copied()
4559 .unwrap_or(0.0);
4560 assert!(
4561 (stored - expected_max_offset).abs() < 0.5,
4562 "expected offset clamped to {expected_max_offset}, got {stored}"
4563 );
4564 }
4565
4566 #[test]
4567 fn virtual_list_dyn_empty_count_realizes_no_children() {
4568 let mut root = crate::tree::virtual_list_dyn(
4569 0,
4570 50.0,
4571 |i| format!("row-{i}"),
4572 |i| crate::widgets::text::text(format!("r{i}")),
4573 );
4574 let mut state = UiState::new();
4575 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4576 assert_eq!(root.children.len(), 0);
4577 }
4578
4579 #[test]
4580 #[should_panic(expected = "estimated_row_height > 0.0")]
4581 fn virtual_list_dyn_zero_estimate_panics() {
4582 let _ = crate::tree::virtual_list_dyn(
4583 10,
4584 0.0,
4585 |i| format!("row-{i}"),
4586 |i| crate::widgets::text::text(format!("r{i}")),
4587 );
4588 }
4589
4590 #[test]
4591 fn text_runs_constructor_shape_smoke() {
4592 let el = crate::tree::text_runs([
4593 crate::widgets::text::text("Hello, "),
4594 crate::widgets::text::text("world").bold(),
4595 crate::tree::hard_break(),
4596 crate::widgets::text::text("of text").italic(),
4597 ]);
4598 assert_eq!(el.kind, Kind::Inlines);
4599 assert_eq!(el.children.len(), 4);
4600 assert!(matches!(
4601 el.children[1].font_weight,
4602 FontWeight::Bold | FontWeight::Semibold
4603 ));
4604 assert_eq!(el.children[2].kind, Kind::HardBreak);
4605 assert!(el.children[3].text_italic);
4606 }
4607
4608 #[test]
4609 fn wrapped_text_hugs_multiline_height_from_available_width() {
4610 let mut root = column([crate::paragraph(
4611 "A longer sentence should wrap into multiple measured lines.",
4612 )])
4613 .width(Size::Fill(1.0))
4614 .height(Size::Hug);
4615
4616 let mut state = UiState::new();
4617 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
4618
4619 let child_rect = state.rect(&root.children[0].computed_id);
4620 assert_eq!(child_rect.w, 180.0);
4621 assert!(
4622 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4623 "expected multiline paragraph height, got {}",
4624 child_rect.h
4625 );
4626 }
4627
4628 #[test]
4629 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
4630 const PANEL_W: f32 = 240.0;
4641 const PADDING: f32 = 18.0;
4642 const GAP: f32 = 12.0;
4643
4644 let panel = column([
4645 crate::paragraph(
4646 "A long enough warning paragraph that it has to wrap onto a second line \
4647 inside this narrow panel.",
4648 ),
4649 crate::widgets::button::button("OK").key("ok"),
4650 ])
4651 .width(Size::Fixed(PANEL_W))
4652 .height(Size::Hug)
4653 .padding(Sides::all(PADDING))
4654 .gap(GAP)
4655 .align(Align::Stretch);
4656
4657 let mut root = crate::stack([panel])
4658 .width(Size::Fill(1.0))
4659 .height(Size::Fill(1.0));
4660 let mut state = UiState::new();
4661 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4662
4663 let panel_rect = state.rect(&root.children[0].computed_id);
4664 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
4665
4666 let para_rect = state.rect(&root.children[0].children[0].computed_id);
4667 let button_rect = state.rect(&root.children[0].children[1].computed_id);
4668
4669 assert!(
4672 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4673 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
4674 got h={}",
4675 para_rect.h
4676 );
4677
4678 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
4684 assert!(
4685 (bottom_padding - PADDING).abs() < 0.5,
4686 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
4687 );
4688 }
4689
4690 #[test]
4691 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
4692 const COL_W: f32 = 600.0;
4704 const GUTTER_W: f32 = 3.0;
4705
4706 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
4707 sed do eiusmod tempor incididunt ut labore et dolore magna \
4708 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
4709 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
4710
4711 let make_row = || {
4712 let gutter = El::new(Kind::Custom("gutter"))
4713 .width(Size::Fixed(GUTTER_W))
4714 .height(Size::Fill(1.0));
4715 let body = crate::paragraph(long).width(Size::Fill(1.0));
4716 crate::row([gutter, body]).width(Size::Fill(1.0))
4717 };
4718
4719 let mut root = column([make_row(), make_row()])
4720 .width(Size::Fixed(COL_W))
4721 .height(Size::Hug)
4722 .align(Align::Stretch);
4723 let mut state = UiState::new();
4724 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
4725
4726 let row0_rect = state.rect(&root.children[0].computed_id);
4727 let row1_rect = state.rect(&root.children[1].computed_id);
4728 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
4729
4730 let line_height = crate::tokens::TEXT_SM.line_height;
4735 assert!(
4736 para0_rect.h > line_height * 1.5,
4737 "paragraph should wrap to multiple lines at ~597px wide; \
4738 got h={} (line_height={})",
4739 para0_rect.h,
4740 line_height,
4741 );
4742 assert!(
4743 row0_rect.h > line_height * 1.5,
4744 "row 0 should accommodate the wrapped paragraph height; \
4745 got h={} (line_height={})",
4746 row0_rect.h,
4747 line_height,
4748 );
4749
4750 assert!(
4752 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
4753 "row 1 starts at y={} but row 0 occupies y={}..{}",
4754 row1_rect.y,
4755 row0_rect.y,
4756 row0_rect.y + row0_rect.h,
4757 );
4758 }
4759
4760 #[test]
4765 fn min_width_floors_resolved_cross_axis_size() {
4766 let mut root = column([crate::widgets::text::text("hi")
4767 .width(Size::Fixed(40.0))
4768 .height(Size::Fixed(20.0))
4769 .min_width(120.0)])
4770 .align(Align::Start)
4771 .width(Size::Fixed(500.0))
4772 .height(Size::Fixed(200.0));
4773 let mut state = UiState::new();
4774 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4775 let child_rect = state.rect(&root.children[0].computed_id);
4776 assert!(
4777 (child_rect.w - 120.0).abs() < 0.5,
4778 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
4779 child_rect.w,
4780 );
4781 }
4782
4783 #[test]
4786 fn max_width_caps_fill_child() {
4787 let mut root = crate::row([crate::widgets::text::text("body")
4788 .width(Size::Fill(1.0))
4789 .height(Size::Fixed(20.0))
4790 .max_width(160.0)])
4791 .width(Size::Fixed(800.0))
4792 .height(Size::Fixed(40.0));
4793 let mut state = UiState::new();
4794 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
4795 let child_rect = state.rect(&root.children[0].computed_id);
4796 assert!(
4797 (child_rect.w - 160.0).abs() < 0.5,
4798 "expected Fill child capped at 160, got w={}",
4799 child_rect.w,
4800 );
4801 }
4802
4803 #[test]
4806 fn min_width_wins_over_max_width_when_conflicting() {
4807 let mut root = column([crate::widgets::text::text("x")
4808 .width(Size::Fixed(50.0))
4809 .height(Size::Fixed(20.0))
4810 .max_width(80.0)
4811 .min_width(120.0)]);
4812 let mut state = UiState::new();
4813 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4814 let child_rect = state.rect(&root.children[0].computed_id);
4815 assert!(
4816 (child_rect.w - 120.0).abs() < 0.5,
4817 "expected min_width (120) to win over max_width (80), got w={}",
4818 child_rect.w,
4819 );
4820 }
4821
4822 #[test]
4826 fn min_height_floors_hug_column_inside_fixed_parent() {
4827 let inner = column([crate::widgets::text::text("a")
4828 .width(Size::Fixed(40.0))
4829 .height(Size::Fixed(20.0))])
4830 .width(Size::Fixed(80.0))
4831 .height(Size::Hug)
4832 .min_height(200.0);
4833 let mut root = column([inner])
4834 .align(Align::Start)
4835 .width(Size::Fixed(800.0))
4836 .height(Size::Fixed(600.0));
4837 let mut state = UiState::new();
4838 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4839 let inner_rect = state.rect(&root.children[0].computed_id);
4840 assert!(
4841 (inner_rect.h - 200.0).abs() < 0.5,
4842 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
4843 inner_rect.h,
4844 );
4845 }
4846
4847 #[test]
4856 fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
4857 let mut root = crate::row([
4861 column([crate::widgets::text::paragraph(
4862 "A long enough description that must wrap to two lines at 148px",
4863 )])
4864 .width(Size::Fill(1.0)),
4865 crate::widgets::text::text("ok")
4866 .width(Size::Fixed(40.0))
4867 .height(Size::Fixed(20.0)),
4868 ])
4869 .gap(12.0)
4870 .align(Align::Center)
4871 .width(Size::Fixed(200.0));
4872 let mut state = UiState::new();
4873 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
4874 let col_rect = state.rect(&root.children[0].computed_id);
4876 let para_rect = state.rect(&root.children[0].children[0].computed_id);
4877 assert!(
4878 (col_rect.h - para_rect.h).abs() < 0.5,
4879 "column height ({}) should track its wrapped child's height ({})",
4880 col_rect.h,
4881 para_rect.h,
4882 );
4883 }
4884
4885 #[test]
4889 fn aspect_on_column_main_axis_derives_from_cross() {
4890 let mut root = column([El::new(Kind::Group)
4891 .width(Size::Fill(1.0))
4892 .height(Size::Aspect(0.5))])
4893 .width(Size::Fixed(200.0))
4894 .height(Size::Fixed(400.0));
4895 let mut state = UiState::new();
4896 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
4897 let r = state.rect(&root.children[0].computed_id);
4898 assert!(
4899 (r.w - 200.0).abs() < 0.5,
4900 "expected w≈200 (Fill), got {}",
4901 r.w,
4902 );
4903 assert!(
4904 (r.h - 100.0).abs() < 0.5,
4905 "expected h≈100 (Aspect 0.5 of 200), got {}",
4906 r.h,
4907 );
4908 }
4909
4910 #[test]
4914 fn aspect_height_pushes_siblings_in_column() {
4915 let mut root = column([
4916 El::new(Kind::Group)
4917 .width(Size::Fill(1.0))
4918 .height(Size::Aspect(0.25)),
4919 crate::widgets::text::text("caption")
4920 .width(Size::Fixed(40.0))
4921 .height(Size::Fixed(20.0)),
4922 ])
4923 .width(Size::Fixed(400.0))
4924 .height(Size::Fixed(500.0));
4925 let mut state = UiState::new();
4926 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
4927 let img = state.rect(&root.children[0].computed_id);
4928 let cap = state.rect(&root.children[1].computed_id);
4929 assert!(
4930 (img.h - 100.0).abs() < 0.5,
4931 "expected aspect-derived height ≈100, got {}",
4932 img.h,
4933 );
4934 assert!(
4935 (cap.y - 100.0).abs() < 0.5,
4936 "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
4937 cap.y,
4938 );
4939 }
4940
4941 #[test]
4945 fn aspect_on_row_cross_axis_derives_from_main() {
4946 let mut root = crate::row([El::new(Kind::Group)
4947 .height(Size::Fill(1.0))
4948 .width(Size::Aspect(2.0))])
4949 .width(Size::Fixed(800.0))
4950 .height(Size::Fixed(200.0));
4951 let mut state = UiState::new();
4952 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
4953 let r = state.rect(&root.children[0].computed_id);
4954 assert!(
4955 (r.h - 200.0).abs() < 0.5,
4956 "expected h≈200 (Fill), got {}",
4957 r.h,
4958 );
4959 assert!(
4960 (r.w - 400.0).abs() < 0.5,
4961 "expected w≈400 (Aspect 2.0 of 200), got {}",
4962 r.w,
4963 );
4964 }
4965
4966 #[test]
4969 fn aspect_on_both_axes_falls_back_to_intrinsic() {
4970 let mut root = column([crate::widgets::text::text("hi")
4971 .width(Size::Aspect(1.0))
4972 .height(Size::Aspect(1.0))])
4973 .width(Size::Fixed(200.0))
4974 .height(Size::Fixed(200.0));
4975 let mut state = UiState::new();
4976 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4977 let r = state.rect(&root.children[0].computed_id);
4978 assert!(
4979 r.w > 0.0 && r.h > 0.0,
4980 "expected finite size for both-Aspect fallback, got {}x{}",
4981 r.w,
4982 r.h,
4983 );
4984 }
4985
4986 #[test]
4990 fn aspect_respects_min_and_max_on_derived_axis() {
4991 let mut root = column([column([El::new(Kind::Group)
4995 .width(Size::Fill(1.0))
4996 .height(Size::Aspect(1.0))
4997 .max_height(120.0)])
4998 .width(Size::Hug)
4999 .height(Size::Hug)])
5000 .width(Size::Fixed(400.0))
5001 .height(Size::Fixed(600.0));
5002 let mut state = UiState::new();
5003 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
5004 let panel = state.rect(&root.children[0].computed_id);
5005 let img = state.rect(&root.children[0].children[0].computed_id);
5006 assert!(
5007 (img.h - 120.0).abs() < 0.5,
5008 "max_height should clamp aspect-derived height to 120, got {}",
5009 img.h,
5010 );
5011 assert!(
5012 (panel.h - 120.0).abs() < 0.5,
5013 "hugging panel should match clamped child (120), got {}",
5014 panel.h,
5015 );
5016
5017 let mut root = column([column([El::new(Kind::Group)
5020 .width(Size::Fill(1.0))
5021 .height(Size::Aspect(0.1))
5022 .min_height(200.0)])
5023 .width(Size::Hug)
5024 .height(Size::Hug)])
5025 .width(Size::Fixed(400.0))
5026 .height(Size::Fixed(600.0));
5027 let mut state = UiState::new();
5028 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
5029 let panel = state.rect(&root.children[0].computed_id);
5030 let img = state.rect(&root.children[0].children[0].computed_id);
5031 assert!(
5032 (img.h - 200.0).abs() < 0.5,
5033 "min_height should bump aspect-derived height to 200, got {}",
5034 img.h,
5035 );
5036 assert!(
5037 (panel.h - 200.0).abs() < 0.5,
5038 "hugging panel should match bumped child (200), got {}",
5039 panel.h,
5040 );
5041 }
5042
5043 #[test]
5046 fn aspect_basis_is_clamped_before_deriving() {
5047 let mut root = column([El::new(Kind::Group)
5053 .width(Size::Fill(1.0))
5054 .height(Size::Aspect(0.5))
5055 .max_width(100.0)])
5056 .width(Size::Fixed(400.0))
5057 .height(Size::Fixed(400.0));
5058 let mut state = UiState::new();
5059 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5060 let img = state.rect(&root.children[0].computed_id);
5061 assert!(
5062 (img.w - 100.0).abs() < 0.5,
5063 "max_width should cap Fill width at 100, got {}",
5064 img.w,
5065 );
5066 assert!(
5067 (img.h - 50.0).abs() < 0.5,
5068 "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
5069 img.h,
5070 );
5071 }
5072
5073 #[test]
5079 fn hug_column_around_fill_aspect_child_does_not_overflow() {
5080 let mut root = column([column([El::new(Kind::Group)
5087 .width(Size::Fill(1.0))
5088 .height(Size::Aspect(0.5))])
5089 .width(Size::Hug)
5090 .height(Size::Hug)])
5091 .width(Size::Fixed(400.0))
5092 .height(Size::Fixed(400.0));
5093 let mut state = UiState::new();
5094 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5095 let panel = state.rect(&root.children[0].computed_id);
5096 let img = state.rect(&root.children[0].children[0].computed_id);
5097 assert!(
5098 (panel.h - 200.0).abs() < 0.5,
5099 "hugging panel should hug to aspect-derived height 200, got {}",
5100 panel.h,
5101 );
5102 assert!(
5103 (img.h - 200.0).abs() < 0.5,
5104 "image should layout to height 200, got {}",
5105 img.h,
5106 );
5107 assert!(
5108 img.bottom() <= panel.bottom() + 0.5,
5109 "image (bottom={}) must fit within hugging panel (bottom={})",
5110 img.bottom(),
5111 panel.bottom(),
5112 );
5113 }
5114
5115 #[test]
5119 fn hugging_parent_sees_aspect_corrected_intrinsic() {
5120 let mut root = column([column([El::new(Kind::Group)
5124 .width(Size::Fixed(80.0))
5125 .height(Size::Aspect(0.5))])
5126 .width(Size::Hug)
5127 .height(Size::Hug)])
5128 .width(Size::Fixed(400.0))
5129 .height(Size::Fixed(400.0))
5130 .align(Align::Start);
5131 let mut state = UiState::new();
5132 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5133 let hugger = state.rect(&root.children[0].computed_id);
5134 assert!(
5135 (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
5136 "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
5137 hugger.w,
5138 hugger.h,
5139 );
5140 }
5141
5142 #[test]
5144 fn max_height_caps_overlay_child_below_intrinsic() {
5145 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
5148 .width(Size::Fixed(40.0))
5149 .height(Size::Fixed(300.0))])
5150 .width(Size::Hug)
5151 .height(Size::Hug)
5152 .max_height(100.0)])
5153 .width(Size::Fixed(600.0))
5154 .height(Size::Fixed(600.0));
5155 let mut state = UiState::new();
5156 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
5157 let child_rect = state.rect(&root.children[0].computed_id);
5158 assert!(
5159 (child_rect.h - 100.0).abs() < 0.5,
5160 "expected child height capped at 100, got h={}",
5161 child_rect.h,
5162 );
5163 }
5164
5165 #[test]
5169 fn user_resizable_publishes_trailing_edge_band() {
5170 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
5171 let mut root = crate::tree::row([
5172 column(Vec::<El>::new())
5173 .key("nav")
5174 .user_resizable()
5175 .width(Size::Fixed(200.0))
5176 .min_width(120.0)
5177 .max_width(420.0),
5178 column(Vec::<El>::new()).width(Size::Fill(1.0)),
5179 ])
5180 .height(Size::Fixed(400.0));
5181 let mut state = UiState::new();
5182 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
5183
5184 assert_eq!(state.resize.bands.len(), 1);
5185 let band = &state.resize.bands[0];
5186 assert_eq!(band.id, "nav");
5187 assert_eq!(band.key.as_deref(), Some("nav"));
5188 assert_eq!(band.sign, 1.0, "trailing edge: drag-right grows");
5189 assert!((band.current - 200.0).abs() < 0.5);
5190 assert_eq!((band.min, band.max), (120.0, 420.0));
5191 assert!((band.band.x - (200.0 - T / 2.0)).abs() < 0.5);
5193 assert!((band.band.w - T).abs() < 0.5);
5194 assert!((band.band.h - 400.0).abs() < 0.5);
5195 }
5196
5197 #[test]
5202 fn user_resizable_last_child_gets_leading_edge_band() {
5203 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
5204 let mut root = crate::tree::row([
5205 column(Vec::<El>::new()).width(Size::Fill(1.0)),
5206 column(Vec::<El>::new())
5207 .key("inspector")
5208 .user_resizable()
5209 .width(Size::Fixed(240.0)),
5210 ])
5211 .height(Size::Fixed(400.0));
5212 let mut state = UiState::new();
5213 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
5214
5215 assert_eq!(state.resize.bands.len(), 1);
5216 let band = &state.resize.bands[0];
5217 assert_eq!(band.sign, -1.0, "leading edge: drag-left grows");
5218 assert!((band.band.x - (560.0 - T / 2.0)).abs() < 0.5);
5220 assert_eq!(band.min, 0.0);
5221 assert!(
5222 (band.max - 800.0).abs() < 0.5,
5223 "no max_width → capped at the parent's inner extent, got {}",
5224 band.max,
5225 );
5226 }
5227
5228 #[test]
5232 fn user_resizable_override_applies_and_clamps() {
5233 let build = || {
5234 crate::tree::row([
5235 column(Vec::<El>::new())
5236 .key("nav")
5237 .user_resizable()
5238 .width(Size::Fixed(200.0))
5239 .min_width(120.0)
5240 .max_width(420.0),
5241 column(Vec::<El>::new()).key("main").width(Size::Fill(1.0)),
5242 ])
5243 .height(Size::Fixed(400.0))
5244 };
5245 let mut state = UiState::new();
5246 state.set_user_size("nav", 300.0);
5247 let mut root = build();
5248 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
5249 let nav = state.rect_of_key("nav").unwrap();
5250 assert!((nav.w - 300.0).abs() < 0.5, "override wins, got {}", nav.w);
5251 let main = state.rect_of_key("main").unwrap();
5252 assert!(
5253 (main.w - 500.0).abs() < 0.5,
5254 "the Fill sibling absorbs the change, got {}",
5255 main.w,
5256 );
5257
5258 state.set_user_size("nav", 9999.0);
5260 let mut root = build();
5261 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
5262 assert!((state.rect_of_key("nav").unwrap().w - 420.0).abs() < 0.5);
5263
5264 state.clear_user_size("nav");
5266 let mut root = build();
5267 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
5268 assert!((state.rect_of_key("nav").unwrap().w - 200.0).abs() < 0.5);
5269 }
5270
5271 #[test]
5275 fn user_resizable_in_column_resizes_height() {
5276 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
5277 let mut root = column([
5278 crate::tree::row(Vec::<El>::new())
5279 .key("topbar")
5280 .user_resizable()
5281 .height(Size::Fixed(100.0))
5282 .min_height(40.0),
5283 crate::tree::row(Vec::<El>::new()).height(Size::Fill(1.0)),
5284 ])
5285 .width(Size::Fixed(800.0));
5286 let mut state = UiState::new();
5287 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
5288
5289 assert_eq!(state.resize.bands.len(), 1);
5290 let band = &state.resize.bands[0];
5291 assert!((band.band.y - (100.0 - T / 2.0)).abs() < 0.5);
5292 assert!((band.band.w - 800.0).abs() < 0.5);
5293 assert_eq!(band.min, 40.0);
5294 assert!((band.current - 100.0).abs() < 0.5);
5295
5296 state.set_user_size("topbar", 250.0);
5297 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
5298 assert!((state.rect_of_key("topbar").unwrap().h - 250.0).abs() < 0.5);
5299 }
5300}