1#![warn(missing_docs)]
36
37use std::cell::RefCell;
38use std::sync::Arc;
39
40use rustc_hash::FxHashSet;
41
42use crate::scroll::{ScrollAlignment, ScrollRequest};
43use crate::state::dyn_height::DynHeightIndex;
44use crate::state::{ScrollAnchor, UiState, VirtualAnchor};
45use crate::text::metrics as text_metrics;
46use crate::tree::*;
47
48#[derive(Clone)]
78pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
79
80impl LayoutFn {
81 pub fn new<F>(f: F) -> Self
84 where
85 F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
86 {
87 LayoutFn(Arc::new(f))
88 }
89}
90
91impl std::fmt::Debug for LayoutFn {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str("LayoutFn(<fn>)")
94 }
95}
96
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub struct LayoutIntrinsicCacheStats {
105 pub hits: u64,
107 pub misses: u64,
109}
110
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
116pub struct LayoutPruneStats {
117 pub subtrees: u64,
119 pub nodes: u64,
121}
122
123thread_local! {
124 static SIZING_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
129 static LAST_SIZING_STATS: RefCell<LayoutIntrinsicCacheStats> =
130 const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
131 static PRUNE_STATS: RefCell<LayoutPruneStats> =
132 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
133 static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
134 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
135}
136
137pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
142 LAST_SIZING_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
143}
144
145pub fn take_prune_stats() -> LayoutPruneStats {
149 LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
150}
151
152#[derive(Clone, Debug)]
175pub enum VirtualMode {
176 Fixed {
178 row_height: f32,
180 },
181 Dynamic {
185 estimated_row_height: f32,
188 append_only: bool,
201 },
202}
203
204#[derive(Clone, Copy, Debug, PartialEq)]
208pub enum VirtualAnchorPolicy {
209 ViewportFraction {
212 y_fraction: f32,
215 },
216 FirstVisible,
219 LastVisible,
222}
223
224impl Default for VirtualAnchorPolicy {
225 fn default() -> Self {
226 Self::ViewportFraction { y_fraction: 0.25 }
227 }
228}
229
230#[derive(Clone)]
237#[non_exhaustive]
238pub struct VirtualItems {
239 pub count: usize,
241 pub mode: VirtualMode,
243 pub anchor_policy: VirtualAnchorPolicy,
246 pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
250 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
253}
254
255impl VirtualItems {
256 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
260 where
261 F: Fn(usize) -> El + Send + Sync + 'static,
262 {
263 assert!(
264 row_height > 0.0,
265 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
266 );
267 VirtualItems {
268 count,
269 mode: VirtualMode::Fixed { row_height },
270 anchor_policy: VirtualAnchorPolicy::default(),
271 row_key: Arc::new(|i| i.to_string()),
272 build_row: Arc::new(build_row),
273 }
274 }
275
276 pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
282 where
283 K: Fn(usize) -> String + Send + Sync + 'static,
284 F: Fn(usize) -> El + Send + Sync + 'static,
285 {
286 assert!(
287 estimated_row_height > 0.0,
288 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
289 );
290 VirtualItems {
291 count,
292 mode: VirtualMode::Dynamic {
293 estimated_row_height,
294 append_only: false,
295 },
296 anchor_policy: VirtualAnchorPolicy::default(),
297 row_key: Arc::new(row_key),
298 build_row: Arc::new(build_row),
299 }
300 }
301
302 pub fn append_only(mut self) -> Self {
306 if let VirtualMode::Dynamic {
307 ref mut append_only,
308 ..
309 } = self.mode
310 {
311 *append_only = true;
312 }
313 self
314 }
315
316 pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
319 self.anchor_policy = policy;
320 self
321 }
322}
323
324impl std::fmt::Debug for VirtualItems {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 f.debug_struct("VirtualItems")
327 .field("count", &self.count)
328 .field("mode", &self.mode)
329 .field("anchor_policy", &self.anchor_policy)
330 .field("row_key", &"<fn>")
331 .field("build_row", &"<fn>")
332 .finish()
333 }
334}
335
336#[non_exhaustive]
341pub struct LayoutCtx<'a> {
342 pub container: Rect,
346 pub children: &'a [El],
349 pub measure: &'a dyn Fn(&El) -> (f32, f32),
353 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
360 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
370}
371
372pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
381 {
382 crate::profile_span!("layout::assign_ids");
383 assign_ids(root);
384 }
385 layout_post_assign(root, ui_state, viewport);
386}
387
388pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
394 SIZING_VISITS.with(|c| c.set(0));
395 PRUNE_STATS.with(|s| *s.borrow_mut() = LayoutPruneStats::default());
396 {
397 crate::profile_span!("layout::root_setup");
398 root.computed_rect = viewport;
399 ui_state
403 .layout
404 .keyed_rects
405 .insert(root.computed_id.clone(), viewport);
406 rebuild_key_index(root, ui_state);
407 ui_state.scroll.metrics.clear();
411 ui_state.scroll.thumb_rects.clear();
412 ui_state.scroll.thumb_tracks.clear();
413 ui_state.scroll.visible_ranges.clear();
414 ui_state.resize.bands.clear();
415 ui_state.viewport.metrics.clear();
422 apply_size_rewrites(root, ui_state, None);
429 }
430 {
431 crate::profile_span!("layout::size");
438 size_tree(root, Some(viewport.w));
439 }
440 {
441 crate::profile_span!("layout::children");
442 layout_children(root, viewport, ui_state);
443 }
444 publish_resize_bands(root, ui_state);
446 LAST_SIZING_STATS.with(|s| {
447 *s.borrow_mut() = LayoutIntrinsicCacheStats {
448 hits: 0,
449 misses: SIZING_VISITS.with(|c| c.get()),
450 };
451 });
452 LAST_PRUNE_STATS.with(|last| *last.borrow_mut() = PRUNE_STATS.with(|s| *s.borrow()));
453}
454
455fn resize_clamp(child: &El, axis: Axis) -> (f32, f32) {
460 let (min, max) = match axis {
461 Axis::Column => (child.min_height, child.max_height),
462 _ => (child.min_width, child.max_width),
463 };
464 let min = min.unwrap_or(0.0).max(0.0);
465 (min, max.unwrap_or(f32::INFINITY).max(min))
466}
467
468fn apply_size_rewrites(node: &mut El, ui_state: &UiState, parent_axis: Option<Axis>) {
477 if node.user_resizable
478 && let Some(axis) = parent_axis
479 && !matches!(axis, Axis::Overlay)
480 {
481 let id = node.key.as_deref().unwrap_or(&node.computed_id);
482 if let Some(&px) = ui_state.resize.overrides.get(id) {
483 let (min, max) = resize_clamp(node, axis);
484 let px = px.clamp(min, max);
485 match axis {
486 Axis::Column => node.height = Size::Fixed(px),
487 _ => node.width = Size::Fixed(px),
488 }
489 }
490 }
491 if let Size::Ch(n) = node.width {
492 node.width = Size::Fixed((n * ch_unit(node)).max(0.0));
493 }
494 if let Size::Ch(n) = node.height {
495 node.height = Size::Fixed((n * ch_unit(node)).max(0.0));
496 }
497 let axis = node.axis;
498 for child in &mut node.children {
499 apply_size_rewrites(child, ui_state, Some(axis));
500 }
501}
502
503fn ch_unit(node: &El) -> f32 {
508 text_metrics::layout_text_with_family(
509 "0",
510 node.font_size,
511 node.font_family,
512 node.font_weight,
513 node.font_mono,
514 node.text_tabular_numerals,
515 TextWrap::NoWrap,
516 None,
517 )
518 .width
519 .max(0.0)
520}
521
522fn publish_resize_bands(node: &El, ui_state: &mut UiState) {
530 use crate::state::resize::{RESIZE_BAND_THICKNESS as T, ResizeBand};
531 let axis = node.axis;
532 if !matches!(axis, Axis::Overlay) && node.children.iter().any(|c| c.user_resizable) {
533 let parent_rect = node.computed_rect;
534 let inner_main = match axis {
535 Axis::Column => parent_rect.h - node.padding.top - node.padding.bottom,
536 _ => parent_rect.w - node.padding.left - node.padding.right,
537 };
538 let count = node.children.len();
539 for (idx, child) in node.children.iter().enumerate() {
540 if !child.user_resizable {
541 continue;
542 }
543 let rect = child.computed_rect;
544 let trailing = idx + 1 < count || count == 1;
548 let band = match (axis, trailing) {
549 (Axis::Column, true) => Rect::new(rect.x, rect.y + rect.h - T / 2.0, rect.w, T),
550 (Axis::Column, false) => Rect::new(rect.x, rect.y - T / 2.0, rect.w, T),
551 (_, true) => Rect::new(rect.x + rect.w - T / 2.0, rect.y, T, rect.h),
552 (_, false) => Rect::new(rect.x - T / 2.0, rect.y, T, rect.h),
553 };
554 let (min, max) = resize_clamp(child, axis);
555 let max = max.min(inner_main.max(min));
559 ui_state.resize.bands.push(ResizeBand {
560 id: child
561 .key
562 .clone()
563 .unwrap_or_else(|| child.computed_id.to_string()),
564 key: child.key.clone(),
565 container_id: node.computed_id.to_string(),
566 band,
567 axis,
568 sign: if trailing { 1.0 } else { -1.0 },
569 current: match axis {
570 Axis::Column => rect.h,
571 _ => rect.w,
572 },
573 min,
574 max,
575 });
576 }
577 }
578 for child in &node.children {
579 publish_resize_bands(child, ui_state);
580 }
581}
582
583pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
590 let mut path = String::with_capacity(parent_id.len() + 24);
591 path.push_str(parent_id);
592 push_id_suffix(&mut path, child, child_index);
593 assign_id(child, &mut path);
594}
595
596fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
601 ui_state.layout.key_index.clear();
602 let mut id_counts: rustc_hash::FxHashMap<&str, u32> = Default::default();
610 fn visit<'a>(
611 node: &'a El,
612 index: &mut rustc_hash::FxHashMap<String, std::sync::Arc<str>>,
613 id_counts: &mut rustc_hash::FxHashMap<&'a str, u32>,
614 ) {
615 if let Some(key) = &node.key {
616 index
617 .entry(key.clone())
618 .or_insert_with(|| node.computed_id.clone());
619 }
620 *id_counts.entry(node.computed_id.as_ref()).or_insert(0) += 1;
621 for c in &node.children {
622 visit(c, index, id_counts);
623 }
624 }
625 visit(root, &mut ui_state.layout.key_index, &mut id_counts);
626 warn_duplicate_ids(&id_counts, &mut ui_state.layout.warned_duplicate_ids);
627}
628
629fn warn_duplicate_ids(
636 id_counts: &rustc_hash::FxHashMap<&str, u32>,
637 warned: &mut rustc_hash::FxHashSet<String>,
638) {
639 for (&id, &n) in id_counts {
640 if n > 1 && warned.insert(id.to_string()) {
641 log::warn!(
642 "DuplicateId: {n} nodes share id {id} — duplicate sibling key; \
643 their rects and intrinsic-cache entries collide silently (issue #64)"
644 );
645 }
646 }
647}
648
649pub fn assign_ids(root: &mut El) {
653 let mut path = String::with_capacity(128);
654 path.push_str("root");
655 assign_id(root, &mut path);
656}
657
658fn push_id_suffix(path: &mut String, child: &El, child_index: usize) {
661 use std::fmt::Write;
662 path.push('.');
663 path.push_str(role_token(&child.kind));
664 match &child.key {
665 Some(k) => {
666 path.push('[');
667 path.push_str(k);
668 path.push(']');
669 }
670 None => {
671 let _ = write!(path, ".{child_index}");
672 }
673 }
674}
675
676fn assign_id(node: &mut El, path: &mut String) {
680 node.computed_id = std::sync::Arc::from(path.as_str());
681 for (i, c) in node.children.iter_mut().enumerate() {
682 let len = path.len();
683 push_id_suffix(path, c, i);
684 assign_id(c, path);
685 path.truncate(len);
686 }
687}
688
689fn role_token(k: &Kind) -> &'static str {
690 match k {
691 Kind::Group => "group",
692 Kind::Card => "card",
693 Kind::Button => "button",
694 Kind::Badge => "badge",
695 Kind::Text => "text",
696 Kind::Heading => "heading",
697 Kind::Spacer => "spacer",
698 Kind::Divider => "divider",
699 Kind::Overlay => "overlay",
700 Kind::Scrim => "scrim",
701 Kind::Modal => "modal",
702 Kind::Scroll => "scroll",
703 Kind::VirtualList => "virtual_list",
704 Kind::Inlines => "inlines",
705 Kind::HardBreak => "hard_break",
706 Kind::Math => "math",
707 Kind::Image => "image",
708 Kind::Surface => "surface",
709 Kind::Vector => "vector",
710 Kind::Scene3D => "scene3d",
711 Kind::Plot => "plot",
712 Kind::Viewport => "viewport",
713 Kind::Custom(name) => name,
714 }
715}
716
717#[inline]
723fn set_rect(node: &mut El, rect: Rect, ui_state: &mut UiState) {
724 node.computed_rect = rect;
725 if node.key.is_some() {
726 ui_state
727 .layout
728 .keyed_rects
729 .insert(node.computed_id.clone(), rect);
730 }
731}
732
733fn find_descendant_rect(node: &El, target_id: &str) -> Option<Rect> {
741 for c in &node.children {
742 if &*c.computed_id == target_id {
743 return Some(c.computed_rect);
744 }
745 if target_id
746 .strip_prefix(&*c.computed_id)
747 .is_some_and(|rest| rest.starts_with('.'))
748 {
749 return find_descendant_rect(c, target_id);
750 }
751 }
752 None
753}
754
755fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
756 if matches!(node.kind, Kind::Inlines) {
757 for c in &mut node.children {
765 set_rect(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
766 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
770 }
771 return;
772 }
773 if let Some(items) = node.virtual_items.as_deref().cloned() {
774 layout_virtual(node, node_rect, items, ui_state);
775 return;
776 }
777 if let Some(layout_fn) = node.layout_override.clone() {
778 layout_custom(node, node_rect, layout_fn, ui_state);
779 if node.scrollable {
780 apply_scroll_offset(node, node_rect, ui_state);
781 }
782 if node.viewport.is_some() {
783 apply_viewport_transform(node, node_rect, ui_state);
784 }
785 return;
786 }
787 match node.axis {
788 Axis::Overlay => {
789 let inner = node_rect.inset(node.padding);
790 let clamp_to_parent = node.viewport.is_none();
794 for c in &mut node.children {
795 let c_rect = overlay_rect(c, inner, node.align, node.justify, clamp_to_parent);
796 resize_if_width_diverged(c, c_rect.w);
797 set_rect(c, c_rect, ui_state);
798 layout_children(c, c_rect, ui_state);
799 }
800 }
801 Axis::Column => layout_axis(node, node_rect, true, ui_state),
802 Axis::Row => layout_axis(node, node_rect, false, ui_state),
803 }
804 if node.scrollable {
805 apply_scroll_offset(node, node_rect, ui_state);
806 }
807 if node.viewport.is_some() {
808 apply_viewport_transform(node, node_rect, ui_state);
809 }
810}
811
812fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
813 let inner = node_rect.inset(node.padding);
814 let measure = |c: &El| c.measured_size;
818 let key_index = &ui_state.layout.key_index;
825 let keyed_rects = &ui_state.layout.keyed_rects;
826 let rect_of_key = |key: &str| -> Option<Rect> {
827 let id = key_index.get(key)?;
828 keyed_rects.get(id).copied()
829 };
830 let rect_of_id = |id: &str| -> Option<Rect> { keyed_rects.get(id).copied() };
831 let rects = (layout_fn.0)(LayoutCtx {
832 container: inner,
833 children: &node.children,
834 measure: &measure,
835 rect_of_key: &rect_of_key,
836 rect_of_id: &rect_of_id,
837 });
838 assert_eq!(
839 rects.len(),
840 node.children.len(),
841 "LayoutFn for {:?} returned {} rects for {} children",
842 node.computed_id,
843 rects.len(),
844 node.children.len(),
845 );
846 for (c, c_rect) in node.children.iter_mut().zip(rects) {
847 resize_if_width_diverged(c, c_rect.w);
851 set_rect(c, c_rect, ui_state);
852 layout_children(c, c_rect, ui_state);
853 }
854}
855
856fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
862 let inner = node_rect.inset(node.padding);
863 match items.mode {
864 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
865 node,
866 inner,
867 items.count,
868 row_height,
869 items.build_row,
870 ui_state,
871 ),
872 VirtualMode::Dynamic {
873 estimated_row_height,
874 append_only,
875 } => {
876 let fns = DynamicVirtualFns {
877 anchor_policy: items.anchor_policy,
878 row_key: items.row_key,
879 build_row: items.build_row,
880 };
881 if append_only {
882 layout_virtual_dynamic_incremental(
883 node,
884 inner,
885 items.count,
886 estimated_row_height,
887 fns,
888 ui_state,
889 );
890 } else {
891 layout_virtual_dynamic(
892 node,
893 inner,
894 items.count,
895 estimated_row_height,
896 fns,
897 ui_state,
898 );
899 }
900 }
901 }
902}
903
904fn resolve_scroll_requests<F, K>(
914 node: &El,
915 inner: Rect,
916 count: usize,
917 row_extent: F,
918 row_for_key: K,
919 ui_state: &mut UiState,
920) -> bool
921where
922 F: Fn(usize) -> (f32, f32),
923 K: Fn(&str) -> Option<usize>,
924{
925 if ui_state.scroll.pending_requests.is_empty() {
926 return false;
927 }
928 let Some(key) = node.key.as_deref() else {
929 return false;
930 };
931 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
932 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
933 pending.into_iter().partition(|req| match req {
934 ScrollRequest::ToRow { list_key, .. } => list_key == key,
935 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
936 ScrollRequest::EnsureVisible { .. } => false,
939 });
940 ui_state.scroll.pending_requests = remaining;
941
942 let mut wrote = false;
943 for req in matched {
944 let (row, align) = match req {
945 ScrollRequest::ToRow { row, align, .. } => (row, align),
946 ScrollRequest::ToRowKey { row_key, align, .. } => {
947 let Some(row) = row_for_key(&row_key) else {
948 continue;
949 };
950 (row, align)
951 }
952 ScrollRequest::EnsureVisible { .. } => continue,
953 };
954 if row >= count {
955 continue;
956 }
957 let (row_top, row_h) = row_extent(row);
958 let row_bottom = row_top + row_h;
959 let viewport_h = inner.h;
960 let current = ui_state
961 .scroll
962 .offsets
963 .get(&*node.computed_id)
964 .copied()
965 .unwrap_or(0.0);
966 let new_offset = match align {
967 ScrollAlignment::Start => row_top,
968 ScrollAlignment::End => row_bottom - viewport_h,
969 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
970 ScrollAlignment::Visible => {
971 if row_top < current {
972 row_top
973 } else if row_bottom > current + viewport_h {
974 row_bottom - viewport_h
975 } else {
976 continue;
977 }
978 }
979 };
980 ui_state
981 .scroll
982 .offsets
983 .insert(node.computed_id.to_string(), new_offset);
984 wrote = true;
985 }
986 wrote
987}
988
989fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
992 let max_offset = (total_h - inner.h).max(0.0);
993 let stored = ui_state
994 .scroll
995 .offsets
996 .get(&*node.computed_id)
997 .copied()
998 .unwrap_or(0.0);
999 let stored = resolve_pin(node, stored, max_offset, ui_state);
1000 let offset = stored.clamp(0.0, max_offset);
1001 ui_state
1002 .scroll
1003 .offsets
1004 .insert(node.computed_id.to_string(), offset);
1005 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1006 offset
1007}
1008
1009fn write_virtual_scroll_metrics(
1010 node: &El,
1011 inner: Rect,
1012 total_h: f32,
1013 max_offset: f32,
1014 offset: f32,
1015 ui_state: &mut UiState,
1016) {
1017 ui_state.scroll.metrics.insert(
1018 node.computed_id.to_string(),
1019 crate::state::ScrollMetrics {
1020 viewport_h: inner.h,
1021 content_h: total_h,
1022 max_offset,
1023 },
1024 );
1025 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
1026}
1027
1028fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
1032 let role = role_token(&child.kind);
1033 let mut path = String::with_capacity(parent_id.len() + 24);
1034 path.push_str(parent_id);
1035 path.push('.');
1036 path.push_str(role);
1037 match &child.key {
1038 Some(k) => {
1039 path.push('[');
1040 path.push_str(k);
1041 path.push(']');
1042 }
1043 None => {
1044 use std::fmt::Write;
1045 let _ = write!(path, ".{global_i}");
1046 }
1047 }
1048 assign_id(child, &mut path);
1049}
1050
1051fn layout_virtual_fixed(
1052 node: &mut El,
1053 inner: Rect,
1054 count: usize,
1055 row_height: f32,
1056 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1057 ui_state: &mut UiState,
1058) {
1059 let gap = node.gap.max(0.0);
1060 let pitch = row_height + gap;
1061 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
1062 resolve_scroll_requests(
1063 node,
1064 inner,
1065 count,
1066 |i| (i as f32 * pitch, row_height),
1067 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
1068 ui_state,
1069 );
1070 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
1071
1072 if count == 0 {
1073 node.children.clear();
1074 return;
1075 }
1076
1077 let start = (offset / pitch).floor() as usize;
1081 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
1082
1083 let mut realized: Vec<El> = Vec::new();
1084 let mut realized_range: Option<(usize, usize)> = None;
1085 for global_i in start..end {
1086 let row_top = global_i as f32 * pitch;
1087 if row_top >= offset + inner.h || row_top + row_height <= offset {
1088 continue;
1089 }
1090 let mut child = (build_row)(global_i);
1091 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
1092 size_tree(&mut child, Some(inner.w));
1095
1096 let row_y = inner.y + row_top - offset;
1097 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
1098 set_rect(&mut child, c_rect, ui_state);
1099 layout_children(&mut child, c_rect, ui_state);
1100 realized.push(child);
1101 realized_range = Some(match realized_range {
1102 None => (global_i, global_i + 1),
1103 Some((s, _)) => (s, global_i + 1),
1104 });
1105 }
1106 if let Some((s, e)) = realized_range {
1107 ui_state
1108 .scroll
1109 .visible_ranges
1110 .insert(node.computed_id.to_string(), (s, e));
1111 }
1112 node.children = realized;
1113}
1114
1115fn layout_virtual_dynamic_incremental(
1127 node: &mut El,
1128 inner: Rect,
1129 count: usize,
1130 estimated_row_height: f32,
1131 fns: DynamicVirtualFns,
1132 ui_state: &mut UiState,
1133) {
1134 let gap = node.gap.max(0.0);
1135 let width_bucket = virtual_width_bucket(inner.w);
1136
1137 if count == 0 {
1138 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1139 ui_state.scroll.dyn_height_index.remove(&*node.computed_id);
1140 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1141 debug_assert_eq!(offset, 0.0);
1142 node.children.clear();
1143 return;
1144 }
1145
1146 let existing = ui_state.scroll.dyn_height_index.remove(&*node.computed_id);
1153 let head_key = (fns.row_key)(0);
1154 let mut trimmed_keys: Vec<String> = Vec::new();
1155 let reconciled = existing.and_then(|mut ix| {
1156 let ok = ix.reconcile(
1157 width_bucket,
1158 estimated_row_height,
1159 count,
1160 &head_key,
1161 |i| (fns.row_key)(i),
1162 |_i, key| {
1163 cached_row_height(
1164 ui_state,
1165 &node.computed_id,
1166 key,
1167 width_bucket,
1168 estimated_row_height,
1169 )
1170 },
1171 &mut trimmed_keys,
1172 );
1173 ok.then_some(ix)
1174 });
1175 let mut index = match reconciled {
1176 Some(ix) => ix,
1177 None => DynHeightIndex::build(width_bucket, estimated_row_height, count, |i| {
1178 let key = (fns.row_key)(i);
1179 let h = cached_row_height(
1180 ui_state,
1181 &node.computed_id,
1182 &key,
1183 width_bucket,
1184 estimated_row_height,
1185 );
1186 (key, h)
1187 }),
1188 };
1189 if !trimmed_keys.is_empty()
1194 && let Some(measured) = ui_state
1195 .scroll
1196 .measured_row_heights
1197 .get_mut(&*node.computed_id)
1198 {
1199 for key in &trimmed_keys {
1200 measured.remove(key);
1201 }
1202 if measured.is_empty() {
1203 ui_state
1204 .scroll
1205 .measured_row_heights
1206 .remove(&*node.computed_id);
1207 }
1208 }
1209
1210 let has_request = node.key.as_deref().is_some_and(|k| {
1213 ui_state.scroll.pending_requests.iter().any(|r| match r {
1214 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1215 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1216 ScrollRequest::EnsureVisible { .. } => false,
1217 })
1218 });
1219 let mut request_wrote = false;
1220 if has_request {
1221 request_wrote = resolve_scroll_requests(
1222 node,
1223 inner,
1224 count,
1225 |target| (index.row_top(target, gap), index.height(target)),
1226 |row_key| index.index_for_key(row_key),
1227 ui_state,
1228 );
1229 }
1230
1231 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1232 let max_offset = (total_h - inner.h).max(0.0);
1233 let stored = ui_state
1234 .scroll
1235 .offsets
1236 .get(&*node.computed_id)
1237 .copied()
1238 .unwrap_or(0.0);
1239 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1240 let provisional_offset = if pin_active {
1241 match node.pin_policy {
1242 crate::tree::PinPolicy::End => max_offset,
1243 crate::tree::PinPolicy::Start => 0.0,
1244 crate::tree::PinPolicy::None => unreachable!(),
1245 }
1246 } else if request_wrote {
1247 stored
1248 } else {
1249 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1250 }
1251 .clamp(0.0, max_offset);
1252
1253 let (measure_start, _, measure_end) = index.visible_range(gap, provisional_offset, inner.h);
1254 measure_dynamic_range(
1255 node,
1256 DynamicRangeCtx {
1257 inner,
1258 keys: KeySource::Func(&*fns.row_key),
1259 width_bucket,
1260 build_row: &fns.build_row,
1261 },
1262 measure_start,
1263 measure_end,
1264 ui_state,
1265 );
1266 refresh_index_range(
1267 &mut index,
1268 &node.computed_id,
1269 width_bucket,
1270 measure_start,
1271 measure_end,
1272 &*fns.row_key,
1273 estimated_row_height,
1274 ui_state,
1275 );
1276
1277 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1278 let max_offset = (total_h - inner.h).max(0.0);
1279 let stored = ui_state
1280 .scroll
1281 .offsets
1282 .get(&*node.computed_id)
1283 .copied()
1284 .unwrap_or(0.0);
1285 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1286 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1287 && ui_state
1288 .scroll
1289 .pin_active
1290 .get(&*node.computed_id)
1291 .copied()
1292 .unwrap_or(false);
1293 let mut offset = if pin_active {
1294 pin_resolved
1295 } else if request_wrote {
1296 stored
1297 } else {
1298 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1299 }
1300 .clamp(0.0, max_offset);
1301
1302 ui_state
1303 .scroll
1304 .offsets
1305 .insert(node.computed_id.to_string(), offset);
1306
1307 let (start, start_y, end) = index.visible_range(gap, offset, inner.h);
1308 let mut realized_rows = layout_dynamic_range(
1309 node,
1310 DynamicRangeCtx {
1311 inner,
1312 keys: KeySource::Func(&*fns.row_key),
1313 width_bucket,
1314 build_row: &fns.build_row,
1315 },
1316 offset,
1317 start,
1318 start_y,
1319 end,
1320 ui_state,
1321 );
1322 refresh_index_range(
1323 &mut index,
1324 &node.computed_id,
1325 width_bucket,
1326 start,
1327 end,
1328 &*fns.row_key,
1329 estimated_row_height,
1330 ui_state,
1331 );
1332
1333 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1334 let max_offset = (total_h - inner.h).max(0.0);
1335 let corrected_offset = if pin_active {
1336 match node.pin_policy {
1337 crate::tree::PinPolicy::End => max_offset,
1338 crate::tree::PinPolicy::Start => 0.0,
1339 crate::tree::PinPolicy::None => unreachable!(),
1340 }
1341 } else if request_wrote {
1342 offset
1343 } else {
1344 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(offset)
1345 }
1346 .clamp(0.0, max_offset);
1347 if (corrected_offset - offset).abs() > 0.01 {
1348 let dy = offset - corrected_offset;
1349 for child in &mut node.children {
1350 shift_subtree_y(child, dy, ui_state);
1351 }
1352 for row in &mut realized_rows {
1353 row.rect.y += dy;
1354 }
1355 offset = corrected_offset;
1356 ui_state
1357 .scroll
1358 .offsets
1359 .insert(node.computed_id.to_string(), offset);
1360 }
1361 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1362 ui_state
1363 .scroll
1364 .pin_prev_max
1365 .insert(node.computed_id.to_string(), max_offset);
1366 }
1367 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1368
1369 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1370 ui_state
1371 .scroll
1372 .virtual_anchors
1373 .insert(node.computed_id.to_string(), anchor);
1374 } else {
1375 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1376 }
1377
1378 ui_state
1379 .scroll
1380 .dyn_height_index
1381 .insert(node.computed_id.to_string(), index);
1382}
1383
1384fn cached_row_height(
1388 ui_state: &UiState,
1389 id: &str,
1390 key: &str,
1391 width_bucket: u32,
1392 estimated_row_height: f32,
1393) -> f32 {
1394 ui_state
1395 .scroll
1396 .measured_row_heights
1397 .get(id)
1398 .and_then(|m| m.get(key))
1399 .and_then(|by_width| by_width.get(&width_bucket))
1400 .copied()
1401 .unwrap_or(estimated_row_height)
1402}
1403
1404#[allow(clippy::too_many_arguments)]
1408fn refresh_index_range(
1409 index: &mut DynHeightIndex,
1410 id: &str,
1411 width_bucket: u32,
1412 start: usize,
1413 end: usize,
1414 row_key: &(dyn Fn(usize) -> String + Send + Sync),
1415 estimated_row_height: f32,
1416 ui_state: &UiState,
1417) {
1418 for idx in start..end {
1419 let key = row_key(idx);
1420 let h = cached_row_height(ui_state, id, &key, width_bucket, estimated_row_height);
1421 index.set_height(idx, h);
1422 }
1423}
1424
1425fn dynamic_anchor_offset_indexed(
1429 node: &El,
1430 index: &DynHeightIndex,
1431 gap: f32,
1432 stored: f32,
1433 ui_state: &UiState,
1434) -> Option<f32> {
1435 let anchor = ui_state.scroll.virtual_anchors.get(&*node.computed_id)?;
1436 let idx = index.index_for_key(&anchor.row_key)?;
1437 let row_h = index.height(idx).max(0.0);
1438 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1439 let scroll_delta = stored - anchor.resolved_offset;
1440 let viewport_y = anchor.viewport_y - scroll_delta;
1441 Some(index.row_top(idx, gap) + row_point - viewport_y)
1442}
1443
1444fn layout_virtual_dynamic(
1445 node: &mut El,
1446 inner: Rect,
1447 count: usize,
1448 estimated_row_height: f32,
1449 fns: DynamicVirtualFns,
1450 ui_state: &mut UiState,
1451) {
1452 let gap = node.gap.max(0.0);
1453 let width_bucket = virtual_width_bucket(inner.w);
1454 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
1455 prune_dynamic_measurements(node, &row_keys, ui_state);
1456
1457 if count == 0 {
1458 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1459 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1460 debug_assert_eq!(offset, 0.0);
1461 node.children.clear();
1462 return;
1463 }
1464
1465 let mut row_heights = dynamic_row_heights(
1466 node,
1467 &row_keys,
1468 width_bucket,
1469 estimated_row_height,
1470 ui_state,
1471 );
1472
1473 let has_request = node.key.as_deref().is_some_and(|k| {
1479 ui_state.scroll.pending_requests.iter().any(|r| match r {
1480 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1481 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1482 ScrollRequest::EnsureVisible { .. } => false,
1483 })
1484 });
1485 let mut request_wrote = false;
1486 if has_request {
1487 request_wrote = resolve_scroll_requests(
1488 node,
1489 inner,
1490 count,
1491 |target| {
1492 (
1493 dynamic_row_top(&row_heights, gap, target),
1494 row_heights[target],
1495 )
1496 },
1497 |row_key| row_keys.iter().position(|key| key == row_key),
1498 ui_state,
1499 );
1500 }
1501
1502 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1503 let max_offset = (total_h - inner.h).max(0.0);
1504 let stored = ui_state
1505 .scroll
1506 .offsets
1507 .get(&*node.computed_id)
1508 .copied()
1509 .unwrap_or(0.0);
1510 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1511 let provisional_offset = if pin_active {
1512 match node.pin_policy {
1513 crate::tree::PinPolicy::End => max_offset,
1514 crate::tree::PinPolicy::Start => 0.0,
1515 crate::tree::PinPolicy::None => unreachable!(),
1516 }
1517 } else if request_wrote {
1518 stored
1519 } else {
1520 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1521 .unwrap_or(stored)
1522 }
1523 .clamp(0.0, max_offset);
1524
1525 let (measure_start, _, measure_end) =
1526 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
1527 measure_dynamic_range(
1528 node,
1529 DynamicRangeCtx {
1530 inner,
1531 keys: KeySource::Slice(&row_keys),
1532 width_bucket,
1533 build_row: &fns.build_row,
1534 },
1535 measure_start,
1536 measure_end,
1537 ui_state,
1538 );
1539
1540 row_heights = dynamic_row_heights(
1541 node,
1542 &row_keys,
1543 width_bucket,
1544 estimated_row_height,
1545 ui_state,
1546 );
1547 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1548 let max_offset = (total_h - inner.h).max(0.0);
1549 let stored = ui_state
1550 .scroll
1551 .offsets
1552 .get(&*node.computed_id)
1553 .copied()
1554 .unwrap_or(0.0);
1555 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1556 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1557 && ui_state
1558 .scroll
1559 .pin_active
1560 .get(&*node.computed_id)
1561 .copied()
1562 .unwrap_or(false);
1563 let mut offset = if pin_active {
1564 pin_resolved
1565 } else if request_wrote {
1566 stored
1567 } else {
1568 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1569 .unwrap_or(stored)
1570 }
1571 .clamp(0.0, max_offset);
1572
1573 ui_state
1574 .scroll
1575 .offsets
1576 .insert(node.computed_id.to_string(), offset);
1577
1578 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
1579 let mut realized_rows = layout_dynamic_range(
1580 node,
1581 DynamicRangeCtx {
1582 inner,
1583 keys: KeySource::Slice(&row_keys),
1584 width_bucket,
1585 build_row: &fns.build_row,
1586 },
1587 offset,
1588 start,
1589 start_y,
1590 end,
1591 ui_state,
1592 );
1593
1594 row_heights = dynamic_row_heights(
1595 node,
1596 &row_keys,
1597 width_bucket,
1598 estimated_row_height,
1599 ui_state,
1600 );
1601 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1602 let max_offset = (total_h - inner.h).max(0.0);
1603 let corrected_offset = if pin_active {
1604 match node.pin_policy {
1605 crate::tree::PinPolicy::End => max_offset,
1606 crate::tree::PinPolicy::Start => 0.0,
1607 crate::tree::PinPolicy::None => unreachable!(),
1608 }
1609 } else if request_wrote {
1610 offset
1611 } else {
1612 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1613 .unwrap_or(offset)
1614 }
1615 .clamp(0.0, max_offset);
1616 if (corrected_offset - offset).abs() > 0.01 {
1617 let dy = offset - corrected_offset;
1618 for child in &mut node.children {
1619 shift_subtree_y(child, dy, ui_state);
1620 }
1621 for row in &mut realized_rows {
1622 row.rect.y += dy;
1623 }
1624 offset = corrected_offset;
1625 ui_state
1626 .scroll
1627 .offsets
1628 .insert(node.computed_id.to_string(), offset);
1629 }
1630 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1631 ui_state
1632 .scroll
1633 .pin_prev_max
1634 .insert(node.computed_id.to_string(), max_offset);
1635 }
1636 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1637
1638 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1639 ui_state
1640 .scroll
1641 .virtual_anchors
1642 .insert(node.computed_id.to_string(), anchor);
1643 } else {
1644 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1645 }
1646}
1647
1648struct DynamicVirtualFns {
1649 anchor_policy: VirtualAnchorPolicy,
1650 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
1651 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1652}
1653
1654#[derive(Clone, Copy)]
1655struct DynamicRangeCtx<'a> {
1656 inner: Rect,
1657 keys: KeySource<'a>,
1658 width_bucket: u32,
1659 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
1660}
1661
1662#[derive(Clone, Copy)]
1668enum KeySource<'a> {
1669 Slice(&'a [String]),
1670 Func(&'a (dyn Fn(usize) -> String + Send + Sync)),
1671}
1672
1673impl KeySource<'_> {
1674 fn key(&self, idx: usize) -> String {
1675 match self {
1676 KeySource::Slice(keys) => keys[idx].clone(),
1677 KeySource::Func(f) => f(idx),
1678 }
1679 }
1680}
1681
1682fn virtual_width_bucket(width: f32) -> u32 {
1683 width.max(0.0).round().min(u32::MAX as f32) as u32
1684}
1685
1686fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
1687 let Some(measurements) = ui_state
1688 .scroll
1689 .measured_row_heights
1690 .get_mut(&*node.computed_id)
1691 else {
1692 return;
1693 };
1694 let live_keys = row_keys
1695 .iter()
1696 .map(String::as_str)
1697 .collect::<FxHashSet<_>>();
1698 measurements.retain(|key, widths| {
1699 let live = live_keys.contains(key.as_str());
1700 if live {
1701 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
1702 }
1703 live && !widths.is_empty()
1704 });
1705 if measurements.is_empty() {
1706 ui_state
1707 .scroll
1708 .measured_row_heights
1709 .remove(&*node.computed_id);
1710 }
1711}
1712
1713fn dynamic_row_heights(
1714 node: &El,
1715 row_keys: &[String],
1716 width_bucket: u32,
1717 estimated_row_height: f32,
1718 ui_state: &UiState,
1719) -> Vec<f32> {
1720 let measurements = ui_state.scroll.measured_row_heights.get(&*node.computed_id);
1721 row_keys
1722 .iter()
1723 .map(|key| {
1724 measurements
1725 .and_then(|m| m.get(key))
1726 .and_then(|by_width| by_width.get(&width_bucket))
1727 .copied()
1728 .unwrap_or(estimated_row_height)
1729 })
1730 .collect()
1731}
1732
1733fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1734 row_heights
1735 .iter()
1736 .take(target)
1737 .fold(0.0, |y, h| y + *h + gap)
1738}
1739
1740fn dynamic_visible_range(
1741 row_heights: &[f32],
1742 gap: f32,
1743 offset: f32,
1744 viewport_h: f32,
1745) -> (usize, f32, usize) {
1746 let count = row_heights.len();
1747 let mut start = 0;
1748 let mut y = 0.0_f32;
1749 while start < count {
1750 let h = row_heights[start];
1751 if y + h > offset {
1752 break;
1753 }
1754 y += h + gap;
1755 start += 1;
1756 }
1757
1758 let mut end = start;
1759 let mut cursor = y;
1760 let viewport_bottom = offset + viewport_h;
1761 while end < count && cursor < viewport_bottom {
1762 let h = row_heights[end];
1763 end += 1;
1764 cursor += h + gap;
1765 }
1766 (start, y, end)
1767}
1768
1769fn dynamic_anchor_offset(
1770 node: &El,
1771 row_keys: &[String],
1772 row_heights: &[f32],
1773 gap: f32,
1774 stored: f32,
1775 ui_state: &UiState,
1776) -> Option<f32> {
1777 let anchor = ui_state.scroll.virtual_anchors.get(&*node.computed_id)?;
1778 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1779 anchor.row_index
1780 } else {
1781 row_keys.iter().position(|key| key == &anchor.row_key)?
1782 };
1783 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1784 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1785 let scroll_delta = stored - anchor.resolved_offset;
1786 let viewport_y = anchor.viewport_y - scroll_delta;
1787 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1788}
1789
1790fn measure_dynamic_range(
1791 node: &El,
1792 ctx: DynamicRangeCtx<'_>,
1793 start: usize,
1794 end: usize,
1795 ui_state: &mut UiState,
1796) {
1797 if start >= end {
1798 return;
1799 }
1800 let mut new_measurements = Vec::new();
1801 for idx in start..end {
1802 let key = ctx.keys.key(idx);
1803 let mut child = (ctx.build_row)(idx);
1804 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1805 size_tree(&mut child, Some(ctx.inner.w));
1811 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1812 new_measurements.push((key, actual_h));
1813 }
1814 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1815}
1816
1817fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1818 match child.height {
1819 Size::Fixed(v) => v.max(0.0),
1820 Size::Ch(n) => (n * ch_unit(child)).max(0.0),
1821 Size::Hug => child.measured_size.1.max(0.0),
1824 Size::Aspect(r) => (width * r).max(0.0),
1825 Size::Fill(_) => panic!(
1826 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1827 or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1828 virtualization",
1829 node.computed_id,
1830 ),
1831 }
1832}
1833
1834const MAX_WIDTH_BUCKETS_PER_ROW: usize = 8;
1841
1842fn store_dynamic_measurements(
1843 node: &El,
1844 width_bucket: u32,
1845 measurements: Vec<(String, f32)>,
1846 ui_state: &mut UiState,
1847) {
1848 if measurements.is_empty() {
1849 return;
1850 }
1851 let entry = ui_state
1852 .scroll
1853 .measured_row_heights
1854 .entry(node.computed_id.to_string())
1855 .or_default();
1856 for (row_key, h) in measurements {
1857 let buckets = entry.entry(row_key).or_default();
1858 buckets.insert(width_bucket, h);
1859 if buckets.len() > MAX_WIDTH_BUCKETS_PER_ROW {
1860 let mut widths: Vec<u32> = buckets.keys().copied().collect();
1863 widths.sort_unstable_by_key(|w| (i64::from(*w) - i64::from(width_bucket)).abs());
1864 for w in widths.drain(MAX_WIDTH_BUCKETS_PER_ROW..) {
1865 buckets.remove(&w);
1866 }
1867 }
1868 }
1869}
1870
1871#[derive(Clone, Debug)]
1872struct DynamicRealizedRow {
1873 index: usize,
1874 key: String,
1875 rect: Rect,
1876}
1877
1878fn layout_dynamic_range(
1879 node: &mut El,
1880 ctx: DynamicRangeCtx<'_>,
1881 offset: f32,
1882 start: usize,
1883 start_y: f32,
1884 end: usize,
1885 ui_state: &mut UiState,
1886) -> Vec<DynamicRealizedRow> {
1887 let gap = node.gap.max(0.0);
1888 let mut cursor_y = start_y;
1889 let mut realized = Vec::new();
1890 let mut realized_rows = Vec::new();
1891 let mut new_measurements = Vec::new();
1892
1893 for idx in start..end {
1894 let key = ctx.keys.key(idx);
1895 let mut child = (ctx.build_row)(idx);
1896 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1897 size_tree(&mut child, Some(ctx.inner.w));
1901 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1902 new_measurements.push((key.clone(), actual_h));
1903
1904 let row_y = ctx.inner.y + cursor_y - offset;
1905 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1906 set_rect(&mut child, c_rect, ui_state);
1907 layout_children(&mut child, c_rect, ui_state);
1908
1909 realized_rows.push(DynamicRealizedRow {
1910 index: idx,
1911 key,
1912 rect: c_rect,
1913 });
1914 realized.push(child);
1915 cursor_y += actual_h + gap;
1916 }
1917
1918 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1919 if let (Some(first), Some(last)) = (realized_rows.first(), realized_rows.last()) {
1920 ui_state
1921 .scroll
1922 .visible_ranges
1923 .insert(node.computed_id.to_string(), (first.index, last.index + 1));
1924 }
1925 node.children = realized;
1926 realized_rows
1927}
1928
1929fn choose_dynamic_anchor(
1930 policy: VirtualAnchorPolicy,
1931 inner: Rect,
1932 offset: f32,
1933 rows: &[DynamicRealizedRow],
1934) -> Option<VirtualAnchor> {
1935 let visible = rows
1936 .iter()
1937 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1938 .collect::<Vec<_>>();
1939 if visible.is_empty() {
1940 return None;
1941 }
1942
1943 let chosen = match policy {
1944 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1945 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1946 visible
1947 .iter()
1948 .min_by(|a, b| {
1949 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1950 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1951 ad.total_cmp(&bd)
1952 })
1953 .copied()
1954 .map(|row| {
1955 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1956 (row.clone(), anchor_y)
1957 })
1958 }
1959 VirtualAnchorPolicy::FirstVisible => {
1960 let row = visible
1961 .iter()
1962 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1963 .or_else(|| visible.first())
1964 .copied()?;
1965 let anchor_y = row.rect.y.max(inner.y);
1966 Some((row.clone(), anchor_y))
1967 }
1968 VirtualAnchorPolicy::LastVisible => {
1969 let row = visible
1970 .iter()
1971 .rev()
1972 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1973 .or_else(|| visible.last())
1974 .copied()?;
1975 let anchor_y = row.rect.bottom().min(inner.bottom());
1976 Some((row.clone(), anchor_y))
1977 }
1978 }?;
1979
1980 let (row, anchor_y) = chosen;
1981 let row_h = row.rect.h.max(0.0);
1982 let row_fraction = if row_h > 0.0 {
1983 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1984 } else {
1985 0.0
1986 };
1987 Some(VirtualAnchor {
1988 row_key: row.key.clone(),
1989 row_index: row.index,
1990 row_fraction,
1991 viewport_y: anchor_y - inner.y,
1992 resolved_offset: offset,
1993 })
1994}
1995
1996fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1997 if y < top {
1998 top - y
1999 } else if y > bottom {
2000 y - bottom
2001 } else {
2002 0.0
2003 }
2004}
2005
2006fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
2007 if count == 0 {
2008 0.0
2009 } else {
2010 row_sum + gap * count.saturating_sub(1) as f32
2011 }
2012}
2013
2014fn apply_scroll_offset(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
2022 let inner = node_rect.inset(node.padding);
2023 if node.children.is_empty() {
2024 ui_state
2025 .scroll
2026 .offsets
2027 .insert(node.computed_id.to_string(), 0.0);
2028 ui_state.scroll.scroll_anchors.remove(&*node.computed_id);
2029 ui_state.scroll.metrics.insert(
2030 node.computed_id.to_string(),
2031 crate::state::ScrollMetrics {
2032 viewport_h: inner.h,
2033 content_h: 0.0,
2034 max_offset: 0.0,
2035 },
2036 );
2037 return;
2038 }
2039 let content_bottom = node
2040 .children
2041 .iter()
2042 .map(|c| c.computed_rect.bottom())
2043 .fold(f32::NEG_INFINITY, f32::max);
2044 let content_h = (content_bottom - inner.y).max(0.0);
2045 let max_offset = (content_h - inner.h).max(0.0);
2046
2047 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
2055
2056 let stored = ui_state
2057 .scroll
2058 .offsets
2059 .get(&*node.computed_id)
2060 .copied()
2061 .unwrap_or(0.0);
2062 let stored = resolve_pin(node, stored, max_offset, ui_state);
2063 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
2064 && ui_state
2065 .scroll
2066 .pin_active
2067 .get(&*node.computed_id)
2068 .copied()
2069 .unwrap_or(false);
2070 let stored = if pin_active || request_wrote {
2071 stored
2072 } else {
2073 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
2074 };
2075 let clamped = stored.clamp(0.0, max_offset);
2076 if clamped > 0.0 {
2077 for c in &mut node.children {
2078 shift_subtree_y(c, -clamped, ui_state);
2079 }
2080 }
2081 ui_state
2082 .scroll
2083 .offsets
2084 .insert(node.computed_id.to_string(), clamped);
2085 ui_state.scroll.metrics.insert(
2086 node.computed_id.to_string(),
2087 crate::state::ScrollMetrics {
2088 viewport_h: inner.h,
2089 content_h,
2090 max_offset,
2091 },
2092 );
2093
2094 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
2095
2096 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped) {
2097 ui_state
2098 .scroll
2099 .scroll_anchors
2100 .insert(node.computed_id.to_string(), anchor);
2101 } else {
2102 ui_state.scroll.scroll_anchors.remove(&*node.computed_id);
2103 }
2104}
2105
2106fn apply_viewport_transform(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
2117 use crate::viewport::ViewportView;
2118 let cfg = node
2119 .viewport
2120 .as_deref()
2121 .copied()
2122 .expect("apply_viewport_transform called on a non-viewport node");
2123 let inner = node_rect.inset(node.padding);
2124 let origin = (inner.x, inner.y);
2125 let content = viewport_content_bbox(node);
2126
2127 let mut view = ui_state
2130 .viewport
2131 .views
2132 .get(&*node.computed_id)
2133 .copied()
2134 .unwrap_or_default();
2135 if let Some(key) = node.key.as_deref() {
2136 let mut i = 0;
2137 while i < ui_state.viewport.pending_requests.len() {
2138 if ui_state.viewport.pending_requests[i].key() == key {
2139 let req = ui_state.viewport.pending_requests.remove(i);
2140 let mut target = apply_viewport_request(&req, cfg, inner, origin, content, view);
2141 if let crate::viewport::FitPolicy::Contain { padding } = cfg.fit
2148 && matches!(
2149 req,
2150 crate::viewport::ViewportRequest::FitContent { .. }
2151 | crate::viewport::ViewportRequest::ResetView { .. }
2152 )
2153 {
2154 target = viewport_fit_view(cfg, inner, origin, content, target, padding);
2155 }
2156 let fly = req.behavior() == crate::viewport::ViewportBehavior::Smooth
2162 && ui_state.animation.mode == crate::state::AnimationMode::Live
2163 && !matches!(cfg.fit, crate::viewport::FitPolicy::Lock { .. })
2164 && inner.w > 0.0
2165 && inner.h > 0.0
2166 && target != view;
2167 let rearm = matches!(
2174 req,
2175 crate::viewport::ViewportRequest::FitContent { .. }
2176 | crate::viewport::ViewportRequest::ResetView { .. }
2177 );
2178 if rearm && !fly {
2179 ui_state.viewport.taken_over.remove(&*node.computed_id);
2180 } else {
2181 ui_state
2182 .viewport
2183 .taken_over
2184 .insert(node.computed_id.to_string());
2185 }
2186 if fly {
2187 let path = crate::viewport::ZoomPath::new(
2188 view_framing(view, inner, origin),
2189 view_framing(target, inner, origin),
2190 );
2191 let ms = (f64::from(path.length()) * VIEWPORT_FLIGHT_MS_PER_UNIT)
2192 .clamp(VIEWPORT_FLIGHT_MS_MIN, VIEWPORT_FLIGHT_MS_MAX);
2193 ui_state.viewport.flights.insert(
2194 node.computed_id.to_string(),
2195 crate::state::ViewportFlight {
2196 path,
2197 started: viewport_clock(ui_state),
2198 duration: std::time::Duration::from_secs_f64(ms / 1000.0),
2199 rearm_on_arrival: rearm,
2200 },
2201 );
2202 } else {
2203 ui_state.viewport.flights.remove(&*node.computed_id);
2205 view = target;
2206 }
2207 } else {
2208 i += 1;
2209 }
2210 }
2211 }
2212
2213 match cfg.fit {
2220 crate::viewport::FitPolicy::Manual => {}
2221 crate::viewport::FitPolicy::Contain { padding } => {
2222 if !ui_state.viewport.taken_over.contains(&*node.computed_id) {
2223 view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2224 }
2225 }
2226 crate::viewport::FitPolicy::Lock { padding } => {
2227 ui_state.viewport.taken_over.remove(&*node.computed_id);
2231 view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2232 }
2233 }
2234
2235 if matches!(cfg.fit, crate::viewport::FitPolicy::Lock { .. }) {
2240 ui_state.viewport.flights.remove(&*node.computed_id);
2243 } else if let Some(flight) = ui_state.viewport.flights.get(&*node.computed_id).copied() {
2244 let t = if flight.duration.is_zero()
2245 || ui_state.animation.mode == crate::state::AnimationMode::Settled
2246 {
2247 1.0
2248 } else {
2249 (viewport_clock(ui_state)
2250 .saturating_duration_since(flight.started)
2251 .as_secs_f32()
2252 / flight.duration.as_secs_f32())
2253 .min(1.0)
2254 };
2255 let (cx, cy, w) = flight.path.sample(ease_in_out_cubic(t));
2256 let w = w.clamp(
2262 inner.w / cfg.max_zoom.max(1e-6),
2263 inner.w / cfg.min_zoom.max(1e-6),
2264 );
2265 view = framing_view((cx, cy, w), inner, origin);
2266 if t >= 1.0 {
2267 ui_state.viewport.flights.remove(&*node.computed_id);
2268 if flight.rearm_on_arrival {
2269 ui_state.viewport.taken_over.remove(&*node.computed_id);
2270 if let crate::viewport::FitPolicy::Contain { padding } = cfg.fit {
2277 view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2278 }
2279 }
2280 }
2281 }
2282
2283 view.zoom = view.zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2286 if let Some(c) = content {
2287 clamp_viewport_pan(&mut view, cfg.pan_bounds, inner, origin, c);
2288 }
2289
2290 if view != ViewportView::default() {
2292 transform_viewport_subtree(node, view, origin, ui_state);
2293 }
2294
2295 ui_state
2296 .viewport
2297 .views
2298 .insert(node.computed_id.to_string(), view);
2299 ui_state.viewport.metrics.insert(
2300 node.computed_id.to_string(),
2301 crate::state::ViewportMetrics {
2302 inner,
2303 content,
2304 cfg,
2305 },
2306 );
2307}
2308
2309fn viewport_content_bbox(node: &El) -> Option<Rect> {
2313 let mut acc: Option<Rect> = None;
2314 for c in &node.children {
2315 let r = c.computed_rect;
2316 acc = Some(acc.map_or(r, |a| union_rect(a, r)));
2317 if let Some(bb) = viewport_content_bbox(c) {
2318 acc = Some(acc.map_or(bb, |a| union_rect(a, bb)));
2319 }
2320 }
2321 acc
2322}
2323
2324fn union_rect(a: Rect, b: Rect) -> Rect {
2326 let x = a.x.min(b.x);
2327 let y = a.y.min(b.y);
2328 let r = a.right().max(b.right());
2329 let bot = a.bottom().max(b.bottom());
2330 Rect::new(x, y, r - x, bot - y)
2331}
2332
2333fn transform_viewport_subtree(
2336 node: &mut El,
2337 view: crate::viewport::ViewportView,
2338 origin: (f32, f32),
2339 ui_state: &mut UiState,
2340) {
2341 for c in &mut node.children {
2342 let rect = c.computed_rect;
2343 let (nx, ny) = view.project((rect.x, rect.y), origin);
2344 c.computed_rect = Rect::new(nx, ny, rect.w * view.zoom, rect.h * view.zoom);
2345 if c.key.is_some()
2350 && let Some(r) = ui_state.layout.keyed_rects.get_mut(&c.computed_id)
2351 {
2352 *r = c.computed_rect;
2353 }
2354 transform_viewport_subtree(c, view, origin, ui_state);
2355 }
2356}
2357
2358const VIEWPORT_FLIGHT_MS_PER_UNIT: f64 = 350.0;
2365const VIEWPORT_FLIGHT_MS_MIN: f64 = 200.0;
2366const VIEWPORT_FLIGHT_MS_MAX: f64 = 800.0;
2367
2368fn viewport_clock(ui_state: &UiState) -> web_time::Instant {
2371 ui_state
2372 .viewport
2373 .clock_override
2374 .unwrap_or_else(web_time::Instant::now)
2375}
2376
2377fn ease_in_out_cubic(t: f32) -> f32 {
2382 if t < 0.5 {
2383 4.0 * t * t * t
2384 } else {
2385 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
2386 }
2387}
2388
2389fn view_framing(
2393 view: crate::viewport::ViewportView,
2394 inner: Rect,
2395 origin: (f32, f32),
2396) -> (f32, f32, f32) {
2397 let c = view.unproject((inner.center_x(), inner.center_y()), origin);
2398 (c.0, c.1, inner.w / view.zoom.max(1e-6))
2399}
2400
2401fn framing_view(
2404 (cx, cy, w): (f32, f32, f32),
2405 inner: Rect,
2406 origin: (f32, f32),
2407) -> crate::viewport::ViewportView {
2408 viewport_center_on(inner, origin, inner.w / w.max(1e-6), (cx, cy))
2409}
2410
2411fn apply_viewport_request(
2417 req: &crate::viewport::ViewportRequest,
2418 cfg: crate::viewport::ViewportConfig,
2419 inner: Rect,
2420 origin: (f32, f32),
2421 content: Option<Rect>,
2422 current: crate::viewport::ViewportView,
2423) -> crate::viewport::ViewportView {
2424 use crate::viewport::{ViewportRequest, ViewportView};
2425 match req {
2426 ViewportRequest::ResetView { .. } => ViewportView::default(),
2427 ViewportRequest::CenterOn { point, .. } => {
2428 viewport_center_on(inner, origin, current.zoom, *point)
2429 }
2430 ViewportRequest::FitContent { padding, .. } => {
2431 viewport_fit_view(cfg, inner, origin, content, current, *padding)
2432 }
2433 ViewportRequest::FrameRect { rect, padding, .. } => {
2434 viewport_fit_rect(cfg, inner, origin, *rect, current, *padding)
2435 }
2436 }
2437}
2438
2439fn viewport_fit_view(
2445 cfg: crate::viewport::ViewportConfig,
2446 inner: Rect,
2447 origin: (f32, f32),
2448 content: Option<Rect>,
2449 current: crate::viewport::ViewportView,
2450 padding: f32,
2451) -> crate::viewport::ViewportView {
2452 match content {
2453 Some(c) if c.w > 0.0 || c.h > 0.0 => {
2454 viewport_fit_rect(cfg, inner, origin, c, current, padding)
2455 }
2456 _ => current,
2457 }
2458}
2459
2460fn viewport_fit_rect(
2469 cfg: crate::viewport::ViewportConfig,
2470 inner: Rect,
2471 origin: (f32, f32),
2472 rect: Rect,
2473 current: crate::viewport::ViewportView,
2474 padding: f32,
2475) -> crate::viewport::ViewportView {
2476 if rect.w <= 0.0 && rect.h <= 0.0 {
2477 return viewport_center_on(inner, origin, current.zoom, (rect.x, rect.y));
2478 }
2479 let avail_w = (inner.w - 2.0 * padding).max(1.0);
2480 let avail_h = (inner.h - 2.0 * padding).max(1.0);
2481 let mut zoom = f32::INFINITY;
2482 if rect.w > 0.0 {
2483 zoom = zoom.min(avail_w / rect.w);
2484 }
2485 if rect.h > 0.0 {
2486 zoom = zoom.min(avail_h / rect.h);
2487 }
2488 if !zoom.is_finite() {
2489 return current;
2490 }
2491 let zoom = zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2492 viewport_center_on(inner, origin, zoom, (rect.center_x(), rect.center_y()))
2493}
2494
2495fn viewport_center_on(
2498 inner: Rect,
2499 origin: (f32, f32),
2500 zoom: f32,
2501 point: (f32, f32),
2502) -> crate::viewport::ViewportView {
2503 crate::viewport::ViewportView {
2504 pan: (
2505 inner.center_x() - origin.0 - zoom * (point.0 - origin.0),
2506 inner.center_y() - origin.1 - zoom * (point.1 - origin.1),
2507 ),
2508 zoom,
2509 }
2510}
2511
2512fn clamp_viewport_pan(
2516 view: &mut crate::viewport::ViewportView,
2517 bounds: crate::viewport::PanBounds,
2518 inner: Rect,
2519 origin: (f32, f32),
2520 content: Rect,
2521) {
2522 if matches!(bounds, crate::viewport::PanBounds::Free) {
2523 return;
2524 }
2525 let (lx, ty) = view.project((content.x, content.y), origin);
2526 let w = content.w * view.zoom;
2527 let h = content.h * view.zoom;
2528 view.pan.0 += clamp_axis_delta(bounds, lx, lx + w, inner.x, inner.right(), w, inner.w);
2529 view.pan.1 += clamp_axis_delta(bounds, ty, ty + h, inner.y, inner.bottom(), h, inner.h);
2530}
2531
2532fn clamp_axis_delta(
2536 bounds: crate::viewport::PanBounds,
2537 lo: f32,
2538 hi: f32,
2539 vlo: f32,
2540 vhi: f32,
2541 size: f32,
2542 vsize: f32,
2543) -> f32 {
2544 use crate::viewport::PanBounds;
2545 match bounds {
2546 PanBounds::Free => 0.0,
2548 PanBounds::Center => {
2551 let vc = 0.5 * (vlo + vhi);
2552 if lo > vc {
2553 vc - lo
2554 } else if hi < vc {
2555 vc - hi
2556 } else {
2557 0.0
2558 }
2559 }
2560 PanBounds::Contain => {
2561 if size <= vsize {
2562 if lo < vlo {
2564 vlo - lo
2565 } else if hi > vhi {
2566 vhi - hi
2567 } else {
2568 0.0
2569 }
2570 } else {
2571 if lo > vlo {
2573 vlo - lo
2574 } else if hi < vhi {
2575 vhi - hi
2576 } else {
2577 0.0
2578 }
2579 }
2580 }
2581 }
2582}
2583
2584fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
2585 let anchor = ui_state.scroll.scroll_anchors.get(&*node.computed_id)?;
2586 let rect = &find_descendant_rect(node, anchor.node_id.as_str())?;
2590 if rect.h <= 0.0 {
2591 return None;
2592 }
2593 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
2594 let scroll_delta = stored - anchor.resolved_offset;
2595 let viewport_y = anchor.viewport_y - scroll_delta;
2596 Some(rect.y - inner.y + rect_point - viewport_y)
2597}
2598
2599fn choose_scroll_anchor(node: &El, inner: Rect, offset: f32) -> Option<ScrollAnchor> {
2600 if inner.h <= 0.0 {
2601 return None;
2602 }
2603 let target_y = inner.y + inner.h * 0.25;
2604 let mut best = None;
2605 for child in &node.children {
2606 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, &mut best);
2607 }
2608 let candidate = best?;
2609 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
2610 let rect_fraction = if candidate.rect.h > 0.0 {
2611 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
2612 } else {
2613 0.0
2614 };
2615 Some(ScrollAnchor {
2616 node_id: candidate.node_id,
2617 rect_fraction,
2618 viewport_y: anchor_y - inner.y,
2619 resolved_offset: offset,
2620 })
2621}
2622
2623#[derive(Clone, Debug)]
2624struct ScrollAnchorCandidate {
2625 node_id: String,
2626 rect: Rect,
2627 distance: f32,
2628 depth: usize,
2629}
2630
2631fn choose_scroll_anchor_in_subtree(
2632 node: &El,
2633 inner: Rect,
2634 target_y: f32,
2635 depth: usize,
2636 best: &mut Option<ScrollAnchorCandidate>,
2637) {
2638 let rect = node.computed_rect;
2639 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
2640 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
2641 let candidate = ScrollAnchorCandidate {
2642 node_id: node.computed_id.clone().to_string(),
2643 rect,
2644 distance,
2645 depth,
2646 };
2647 let replace = best.as_ref().is_none_or(|current| {
2648 candidate.distance < current.distance
2649 || (candidate.distance == current.distance && candidate.depth > current.depth)
2650 || (candidate.distance == current.distance
2651 && candidate.depth == current.depth
2652 && candidate.rect.h < current.rect.h)
2653 });
2654 if replace {
2655 *best = Some(candidate);
2656 }
2657 }
2658
2659 if node.scrollable {
2660 return;
2661 }
2662 for child in &node.children {
2663 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, best);
2664 }
2665}
2666
2667const PIN_EPSILON: f32 = 0.5;
2672
2673fn pin_would_be_active(
2681 node: &El,
2682 stored: f32,
2683 _max_offset: f32,
2684 ui_state: &UiState,
2685) -> Option<bool> {
2686 let prev_active = ui_state.scroll.pin_active.get(&*node.computed_id).copied();
2687 match node.pin_policy {
2688 crate::tree::PinPolicy::None => None,
2689 crate::tree::PinPolicy::End => {
2690 let prev_max = ui_state
2691 .scroll
2692 .pin_prev_max
2693 .get(&*node.computed_id)
2694 .copied();
2695 Some(match prev_active {
2696 None => true,
2697 Some(prev) => {
2698 let prev_max = prev_max.unwrap_or(0.0);
2699 if prev && stored < prev_max - PIN_EPSILON {
2700 false
2701 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
2702 true
2703 } else {
2704 prev
2705 }
2706 }
2707 })
2708 }
2709 crate::tree::PinPolicy::Start => Some(match prev_active {
2710 None => true,
2711 Some(prev) => {
2712 if prev && stored > PIN_EPSILON {
2713 false
2714 } else if !prev && stored <= PIN_EPSILON {
2715 true
2716 } else {
2717 prev
2718 }
2719 }
2720 }),
2721 }
2722}
2723
2724fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
2737 if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2738 ui_state.scroll.pin_active.remove(&*node.computed_id);
2739 ui_state.scroll.pin_prev_max.remove(&*node.computed_id);
2740 return stored;
2741 }
2742 let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
2743 ui_state
2744 .scroll
2745 .pin_active
2746 .insert(node.computed_id.to_string(), active);
2747 match node.pin_policy {
2748 crate::tree::PinPolicy::End => {
2749 ui_state
2750 .scroll
2751 .pin_prev_max
2752 .insert(node.computed_id.to_string(), max_offset);
2753 if active { max_offset } else { stored }
2754 }
2755 crate::tree::PinPolicy::Start => {
2756 ui_state.scroll.pin_prev_max.remove(&*node.computed_id);
2760 if active { 0.0 } else { stored }
2761 }
2762 crate::tree::PinPolicy::None => unreachable!(),
2763 }
2764}
2765
2766fn resolve_ensure_visible_for_scroll(
2779 node: &El,
2780 inner: Rect,
2781 content_h: f32,
2782 ui_state: &mut UiState,
2783) -> bool {
2784 if ui_state.scroll.pending_requests.is_empty() {
2785 return false;
2786 }
2787 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
2788 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
2789 let mut wrote = false;
2790 for req in pending {
2791 let ScrollRequest::EnsureVisible {
2792 container_key,
2793 y,
2794 h,
2795 } = &req
2796 else {
2797 remaining.push(req);
2798 continue;
2799 };
2800 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
2801 remaining.push(req);
2806 continue;
2807 };
2808 let inside = node.computed_id == *ancestor_id
2811 || node
2812 .computed_id
2813 .strip_prefix(ancestor_id.as_ref())
2814 .is_some_and(|rest| rest.starts_with('.'));
2815 if !inside {
2816 remaining.push(req);
2817 continue;
2818 }
2819 let current = ui_state
2820 .scroll
2821 .offsets
2822 .get(&*node.computed_id)
2823 .copied()
2824 .unwrap_or(0.0);
2825 let target_top = *y;
2826 let target_bottom = *y + *h;
2827 let viewport_h = inner.h;
2828 let new_offset = if target_top < current {
2835 target_top
2836 } else if target_bottom > current + viewport_h {
2837 target_bottom - viewport_h
2838 } else {
2839 continue;
2844 };
2845 let max = (content_h - viewport_h).max(0.0);
2849 let new_offset = new_offset.clamp(0.0, max);
2850 ui_state
2851 .scroll
2852 .offsets
2853 .insert(node.computed_id.to_string(), new_offset);
2854 wrote = true;
2855 }
2856 ui_state.scroll.pending_requests = remaining;
2857 wrote
2858}
2859
2860fn write_thumb_rect(
2868 node: &El,
2869 inner: Rect,
2870 content_h: f32,
2871 max_offset: f32,
2872 offset: f32,
2873 ui_state: &mut UiState,
2874) {
2875 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
2876 return;
2877 }
2878 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
2879 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
2880 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
2881 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
2882 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
2883 let track_remaining = (inner.h - thumb_h).max(0.0);
2884 let thumb_y = inner.y + track_remaining * (offset / max_offset);
2885 let edge = if node.scrollbar_gutter {
2890 inner.right() + crate::tokens::SCROLLBAR_GUTTER
2891 } else {
2892 inner.right()
2893 };
2894 let thumb_x = edge - thumb_w - track_inset;
2895 let track_x = edge - track_w - track_inset;
2896 ui_state.scroll.thumb_rects.insert(
2897 node.computed_id.to_string(),
2898 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
2899 );
2900 ui_state.scroll.thumb_tracks.insert(
2901 node.computed_id.to_string(),
2902 Rect::new(track_x, inner.y, track_w, inner.h),
2903 );
2904}
2905
2906fn shift_subtree_y(node: &mut El, dy: f32, ui_state: &mut UiState) {
2907 node.computed_rect.y += dy;
2908 if node.key.is_some()
2909 && let Some(rect) = ui_state.layout.keyed_rects.get_mut(&node.computed_id)
2910 {
2911 rect.y += dy;
2912 }
2913 if node.scrollbar {
2916 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&*node.computed_id) {
2917 thumb.y += dy;
2918 }
2919 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&*node.computed_id) {
2920 track.y += dy;
2921 }
2922 }
2923 for c in &mut node.children {
2924 shift_subtree_y(c, dy, ui_state);
2925 }
2926}
2927
2928#[derive(Default)]
2935struct AxisScratch {
2936 main_sizes: Vec<f32>,
2937 fill_weights: Vec<Option<f32>>,
2938 row_slots: Vec<Option<(f32, f32)>>,
2939}
2940
2941impl AxisScratch {
2942 fn take() -> Self {
2943 AXIS_SCRATCH_POOL
2944 .with_borrow_mut(|pool| pool.pop())
2945 .unwrap_or_default()
2946 }
2947
2948 fn release(mut self) {
2949 self.main_sizes.clear();
2950 self.fill_weights.clear();
2951 self.row_slots.clear();
2952 AXIS_SCRATCH_POOL.with_borrow_mut(|pool| {
2953 if pool.len() < 256 {
2955 pool.push(self);
2956 }
2957 });
2958 }
2959}
2960
2961thread_local! {
2962 static AXIS_SCRATCH_POOL: std::cell::RefCell<Vec<AxisScratch>> =
2963 const { std::cell::RefCell::new(Vec::new()) };
2964}
2965
2966fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
2967 let inner = node_rect.inset(node.padding);
2968 let n = node.children.len();
2969 if n == 0 {
2970 return;
2971 }
2972 let mut scratch = AxisScratch::take();
2973
2974 let total_gap = node.gap * n.saturating_sub(1) as f32;
2975 let main_extent = if vertical { inner.h } else { inner.w };
2976 let cross_extent = if vertical { inner.w } else { inner.h };
2977
2978 let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
2987 let main_intent = if vertical { c.height } else { c.width };
2988 if let Size::Aspect(r) = main_intent {
2989 let cross_intent = if vertical { c.width } else { c.height };
2990 if !matches!(cross_intent, Size::Aspect(_)) {
2991 let cross_intrinsic = if vertical { iw } else { ih };
2992 let cross_size = match cross_intent {
2993 Size::Fixed(v) => v,
2994 Size::Ch(n) => n * ch_unit(c),
2995 Size::Hug | Size::Fill(_) => match node.align {
2996 Align::Stretch => cross_extent,
2997 Align::Start | Align::Center | Align::End => cross_intrinsic,
2998 },
2999 Size::Aspect(_) => unreachable!(),
3000 };
3001 let cross_size = if vertical {
3002 clamp_w(c, cross_size)
3003 } else {
3004 clamp_h(c, cross_size)
3005 };
3006 let main = cross_size * r.max(0.0);
3007 let clamped = if vertical {
3008 clamp_h(c, main)
3009 } else {
3010 clamp_w(c, main)
3011 };
3012 return MainSize::Resolved(clamped);
3013 }
3014 }
3015 main_size_of(c, iw, ih, vertical)
3016 };
3017
3018 let AxisScratch {
3030 main_sizes,
3031 fill_weights,
3032 ..
3033 } = &mut scratch;
3034 let mut consumed = 0.0;
3035 for c in node.children.iter() {
3036 let (iw, ih) = c.measured_size;
3039 match resolve_main(c, iw, ih) {
3040 MainSize::Resolved(v) => {
3041 consumed += v;
3042 main_sizes.push(v);
3043 fill_weights.push(None);
3044 }
3045 MainSize::Fill(w) => {
3046 main_sizes.push(0.0);
3047 fill_weights.push(Some(w.max(0.001)));
3048 }
3049 }
3050 }
3051 let mut remaining = (main_extent - consumed - total_gap).max(0.0);
3052 loop {
3053 let flexible_weight: f32 = fill_weights.iter().flatten().sum();
3054 if flexible_weight == 0.0 {
3055 break;
3056 }
3057 let mut frozen_any = false;
3058 let mut newly_frozen = 0.0;
3059 for (i, c) in node.children.iter().enumerate() {
3060 let Some(w) = fill_weights[i] else { continue };
3061 let raw = remaining * w / flexible_weight;
3062 let clamped = if vertical {
3063 clamp_h(c, raw)
3064 } else {
3065 clamp_w(c, raw)
3066 };
3067 main_sizes[i] = clamped;
3068 if clamped != raw {
3069 fill_weights[i] = None;
3070 frozen_any = true;
3071 newly_frozen += clamped;
3072 }
3073 }
3074 if !frozen_any {
3075 remaining = 0.0;
3077 break;
3078 }
3079 remaining = (remaining - newly_frozen).max(0.0);
3080 }
3081
3082 let free_after_used = remaining;
3086 let mut cursor = match node.justify {
3087 Justify::Start => 0.0,
3088 Justify::Center => free_after_used * 0.5,
3089 Justify::End => free_after_used,
3090 Justify::SpaceBetween => 0.0,
3091 };
3092 let between_extra = if matches!(node.justify, Justify::SpaceBetween) && n > 1 {
3093 free_after_used / (n - 1) as f32
3094 } else {
3095 0.0
3096 };
3097 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
3098
3099 crate::profile_span!("layout::axis::place");
3100 for (i, c) in node.children.iter_mut().enumerate() {
3101 let (iw, ih) = c.measured_size;
3102 let main_size = main_sizes[i];
3103
3104 let cross_intent = if vertical { c.width } else { c.height };
3105 let cross_intrinsic = if vertical { iw } else { ih };
3106 let cross_size = match cross_intent {
3118 Size::Fixed(v) => v,
3119 Size::Ch(n) => n * ch_unit(c),
3120 Size::Aspect(r) => main_size * r,
3121 Size::Hug | Size::Fill(_) => match node.align {
3122 Align::Stretch => cross_extent,
3123 Align::Start | Align::Center | Align::End => cross_intrinsic,
3124 },
3125 };
3126 let cross_size = if vertical {
3127 clamp_w(c, cross_size)
3128 } else {
3129 clamp_h(c, cross_size)
3130 };
3131
3132 let cross_off = match node.align {
3133 Align::Start | Align::Stretch => 0.0,
3134 Align::Center => (cross_extent - cross_size) * 0.5,
3135 Align::End => cross_extent - cross_size,
3136 };
3137
3138 let c_rect = if vertical {
3139 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
3140 } else {
3141 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
3142 };
3143 set_rect(c, c_rect, ui_state);
3144 if can_prune_scroll_child(c, c_rect, scroll_visible) {
3145 let nodes = zero_descendant_rects(c, c_rect, ui_state);
3146 record_pruned_subtree(nodes);
3147 } else {
3148 resize_if_width_diverged(c, c_rect.w);
3149 layout_children(c, c_rect, ui_state);
3150 }
3151
3152 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
3153 }
3154 scratch.release();
3155}
3156
3157const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
3158
3159fn scroll_visible_content_rect(
3160 node: &El,
3161 inner: Rect,
3162 vertical: bool,
3163 ui_state: &UiState,
3164) -> Option<Rect> {
3165 if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
3166 return None;
3167 }
3168 let offset = ui_state
3169 .scroll
3170 .offsets
3171 .get(&*node.computed_id)
3172 .copied()
3173 .unwrap_or(0.0)
3174 .max(0.0);
3175 Some(Rect::new(
3176 inner.x,
3177 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
3178 inner.w,
3179 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
3180 ))
3181}
3182
3183fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
3184 let Some(visible) = visible else {
3185 return false;
3186 };
3187 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
3188}
3189
3190pub(crate) fn subtree_is_layout_confined(node: &El) -> bool {
3193 if node.translate != (0.0, 0.0)
3194 || node.scale != 1.0
3195 || node.shadow > 0.0
3196 || node.paint_overflow != Sides::zero()
3197 || node.hit_overflow != Sides::zero()
3198 || node.layout_override.is_some()
3199 || node.virtual_items.is_some()
3200 {
3201 return false;
3202 }
3203 node.children.iter().all(subtree_is_layout_confined)
3204}
3205
3206fn zero_descendant_rects(node: &mut El, rect: Rect, ui_state: &mut UiState) -> u64 {
3207 let mut count = 0;
3208 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
3209 for child in &mut node.children {
3210 set_rect(child, zero, ui_state);
3211 count += 1 + zero_descendant_rects(child, zero, ui_state);
3212 }
3213 count
3214}
3215
3216fn record_pruned_subtree(nodes: u64) {
3217 PRUNE_STATS.with(|stats| {
3218 let mut stats = stats.borrow_mut();
3219 stats.subtrees += 1;
3220 stats.nodes += nodes;
3221 });
3222}
3223
3224enum MainSize {
3225 Resolved(f32),
3226 Fill(f32),
3227}
3228
3229fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
3230 let s = if vertical { c.height } else { c.width };
3231 let intr = if vertical { ih } else { iw };
3232 let clamp = |v: f32| {
3233 if vertical {
3234 clamp_h(c, v)
3235 } else {
3236 clamp_w(c, v)
3237 }
3238 };
3239 match s {
3240 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
3241 Size::Ch(n) => MainSize::Resolved(clamp(n * ch_unit(c))),
3242 Size::Hug => MainSize::Resolved(clamp(intr)),
3243 Size::Fill(w) => MainSize::Fill(w),
3244 Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
3251 }
3252}
3253
3254fn overlay_rect(
3263 c: &El,
3264 parent: Rect,
3265 align: Align,
3266 justify: Justify,
3267 clamp_to_parent: bool,
3268) -> Rect {
3269 let hug_w = |iw: f32| {
3272 if clamp_to_parent {
3273 iw.min(parent.w)
3274 } else {
3275 iw
3276 }
3277 };
3278 let hug_h = |ih: f32| {
3279 if clamp_to_parent {
3280 ih.min(parent.h)
3281 } else {
3282 ih
3283 }
3284 };
3285 let (iw, ih) = c.measured_size;
3290 let (w, h) = match (c.width, c.height) {
3295 (Size::Aspect(_), Size::Aspect(_)) => (hug_w(iw), hug_h(ih)),
3296 (Size::Aspect(r), _) => {
3297 let h = match c.height {
3298 Size::Fixed(v) => v,
3299 Size::Ch(n) => n * ch_unit(c),
3300 Size::Hug => hug_h(ih),
3301 Size::Fill(_) => parent.h,
3302 Size::Aspect(_) => unreachable!(),
3303 };
3304 (h * r, h)
3305 }
3306 (_, Size::Aspect(r)) => {
3307 let w = match c.width {
3308 Size::Fixed(v) => v,
3309 Size::Ch(n) => n * ch_unit(c),
3310 Size::Hug => hug_w(iw),
3311 Size::Fill(_) => parent.w,
3312 Size::Aspect(_) => unreachable!(),
3313 };
3314 (w, w * r)
3315 }
3316 _ => {
3317 let w = match c.width {
3318 Size::Fixed(v) => v,
3319 Size::Ch(n) => n * ch_unit(c),
3320 Size::Hug => hug_w(iw),
3321 Size::Fill(_) => parent.w,
3322 Size::Aspect(_) => unreachable!(),
3323 };
3324 let h = match c.height {
3325 Size::Fixed(v) => v,
3326 Size::Ch(n) => n * ch_unit(c),
3327 Size::Hug => hug_h(ih),
3328 Size::Fill(_) => parent.h,
3329 Size::Aspect(_) => unreachable!(),
3330 };
3331 (w, h)
3332 }
3333 };
3334 let w = clamp_w(c, w);
3335 let h = clamp_h(c, h);
3336 let x = match align {
3337 Align::Start | Align::Stretch => parent.x,
3338 Align::Center => parent.x + (parent.w - w) * 0.5,
3339 Align::End => parent.right() - w,
3340 };
3341 let y = match justify {
3342 Justify::Start | Justify::SpaceBetween => parent.y,
3343 Justify::Center => parent.y + (parent.h - h) * 0.5,
3344 Justify::End => parent.bottom() - h,
3345 };
3346 Rect::new(x, y, w, h)
3347}
3348
3349pub fn intrinsic(c: &El) -> (f32, f32) {
3354 intrinsic_constrained(c, None)
3355}
3356
3357fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
3358 let available_width = match c.width {
3363 Size::Fixed(v) => Some(v),
3364 _ => available_width,
3365 };
3366 apply_aspect(
3367 c,
3368 available_width,
3369 intrinsic_constrained_uncached(c, available_width),
3370 )
3371}
3372
3373fn size_tree(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3390 SIZING_VISITS.with(|c| c.set(c.get() + 1));
3391 let available_width = match node.width {
3398 Size::Fixed(v) => Some(v),
3399 Size::Ch(n) => Some(n * ch_unit(node)),
3400 _ => available_width,
3401 };
3402 let inner = size_tree_inner(node, available_width);
3403 let size = apply_aspect(node, available_width, inner);
3404 node.measured_size = size;
3405 node.sized_at_width = available_width.unwrap_or(size.0);
3408 node.width_sensitive = (node.text.is_some() && matches!(node.text_wrap, TextWrap::Wrap))
3415 || matches!(node.kind, Kind::Inlines)
3416 || node.layout_override.is_some()
3417 || node.virtual_items.is_some()
3418 || matches!(node.width, Size::Aspect(_))
3419 || matches!(node.height, Size::Aspect(_))
3420 || node.children.iter().any(|c| c.width_sensitive);
3421 size
3422}
3423
3424#[inline]
3433fn resize_if_width_diverged(c: &mut El, final_w: f32) {
3434 if c.width_sensitive && !c.children.is_empty() && (final_w - c.sized_at_width).abs() > 0.5 {
3435 size_tree(c, Some(final_w));
3436 }
3437}
3438
3439fn size_tree_inner(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3440 if let Some(size) = leaf_intrinsic(node, available_width) {
3441 if node.layout_override.is_some() {
3442 for ch in &mut node.children {
3447 size_tree(ch, None);
3448 }
3449 } else if !node.children.is_empty()
3450 && node.virtual_items.is_none()
3451 && !matches!(node.kind, Kind::Inlines)
3452 {
3453 size_tree_children(node, Some(size.0));
3461 }
3462 return size;
3463 }
3464 size_tree_children(node, available_width)
3465}
3466
3467fn size_tree_children(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3471 match node.axis {
3472 Axis::Overlay => {
3473 let child_available =
3474 available_width.map(|w| (w - node.padding.left - node.padding.right).max(0.0));
3475 let mut w: f32 = 0.0;
3476 let mut h: f32 = 0.0;
3477 for ch in &mut node.children {
3478 let ca = if matches!(ch.width, Size::Aspect(_)) {
3484 None
3485 } else {
3486 child_available
3487 };
3488 let (cw, chh) = size_tree(ch, ca);
3489 w = w.max(cw);
3490 h = h.max(chh);
3491 }
3492 apply_min(
3493 node,
3494 w + node.padding.left + node.padding.right,
3495 h + node.padding.top + node.padding.bottom,
3496 )
3497 }
3498 Axis::Column => {
3499 let mut w: f32 = 0.0;
3500 let mut h: f32 = node.padding.top + node.padding.bottom;
3501 let n = node.children.len();
3502 let child_available =
3503 available_width.map(|w| (w - node.padding.left - node.padding.right).max(0.0));
3504 for (i, ch) in node.children.iter_mut().enumerate() {
3505 let (cw, chh) = size_tree(ch, child_available);
3506 w = w.max(cw);
3507 h += chh;
3508 if i + 1 < n {
3509 h += node.gap;
3510 }
3511 }
3512 apply_min(node, w + node.padding.left + node.padding.right, h)
3513 }
3514 Axis::Row => {
3515 let n = node.children.len();
3525 let total_gap = node.gap * n.saturating_sub(1) as f32;
3526 let inner_available = available_width
3527 .map(|w| (w - node.padding.left - node.padding.right - total_gap).max(0.0));
3528
3529 let mut scratch = AxisScratch::take();
3530 let mut consumed: f32 = 0.0;
3531 let mut fill_weight_total: f32 = 0.0;
3532 for ch in &mut node.children {
3533 match ch.width {
3534 Size::Fill(w) => {
3535 fill_weight_total += w.max(0.001);
3536 scratch.row_slots.push(None);
3537 }
3538 _ => {
3539 let (cw, chh) = size_tree(ch, None);
3540 consumed += cw;
3541 scratch.row_slots.push(Some((cw, chh)));
3542 }
3543 }
3544 }
3545
3546 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3547 let mut w_total: f32 = node.padding.left + node.padding.right;
3548 let mut h_max: f32 = 0.0;
3549 for (i, ch) in node.children.iter_mut().enumerate() {
3550 let (cw, chh) = match scratch.row_slots[i] {
3551 Some(rc) => rc,
3552 None => match (fill_remaining, fill_weight_total > 0.0) {
3553 (Some(av), true) => {
3554 let weight = match ch.width {
3555 Size::Fill(w) => w.max(0.001),
3556 _ => 1.0,
3557 };
3558 size_tree(ch, Some(av * weight / fill_weight_total))
3559 }
3560 _ => size_tree(ch, None),
3561 },
3562 };
3563 w_total += cw;
3564 if i + 1 < n {
3565 w_total += node.gap;
3566 }
3567 h_max = h_max.max(chh);
3568 }
3569 scratch.release();
3570 apply_min(
3571 node,
3572 w_total,
3573 h_max + node.padding.top + node.padding.bottom,
3574 )
3575 }
3576 }
3577}
3578
3579fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
3594 match (c.width, c.height) {
3595 (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
3596 (Size::Aspect(r), _) => {
3597 (clamp_w(c, ih * r.max(0.0)), ih)
3602 }
3603 (_, Size::Aspect(r)) => {
3604 let raw_basis = match c.width {
3605 Size::Fixed(v) => v,
3606 Size::Ch(n) => n * ch_unit(c),
3607 Size::Fill(_) => available_width.unwrap_or(iw),
3608 Size::Hug | Size::Aspect(_) => iw,
3609 };
3610 let basis = clamp_w(c, raw_basis);
3614 (iw, clamp_h(c, basis * r.max(0.0)))
3615 }
3616 _ => (iw, ih),
3617 }
3618}
3619
3620fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
3621 if let Some(size) = leaf_intrinsic(c, available_width) {
3622 return size;
3623 }
3624 container_intrinsic(c, available_width)
3625}
3626
3627fn leaf_intrinsic(c: &El, available_width: Option<f32>) -> Option<(f32, f32)> {
3634 if c.layout_override.is_some() {
3635 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3640 panic!(
3641 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
3642 Size::Hug is not supported for custom layouts",
3643 c.computed_id,
3644 );
3645 }
3646 return Some(apply_min(c, 0.0, 0.0));
3647 }
3648 if c.virtual_items.is_some() {
3649 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3654 panic!(
3655 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
3656 Size::Hug would defeat virtualization",
3657 c.computed_id,
3658 );
3659 }
3660 return Some(apply_min(c, 0.0, 0.0));
3661 }
3662 if matches!(c.kind, Kind::Inlines) {
3663 return Some(inline_paragraph_intrinsic(c, available_width));
3664 }
3665 if matches!(c.kind, Kind::HardBreak) {
3666 return Some(apply_min(c, 0.0, 0.0));
3670 }
3671 if matches!(c.kind, Kind::Math) {
3672 if let Some(expr) = &c.math {
3673 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
3674 return Some(apply_min(
3675 c,
3676 layout.width + c.padding.left + c.padding.right,
3677 layout.height() + c.padding.top + c.padding.bottom,
3678 ));
3679 }
3680 return Some(apply_min(c, 0.0, 0.0));
3681 }
3682 if c.icon.is_some() {
3683 return Some(apply_min(
3684 c,
3685 c.font_size + c.padding.left + c.padding.right,
3686 c.font_size + c.padding.top + c.padding.bottom,
3687 ));
3688 }
3689 if let Some(img) = &c.image {
3690 let w = img.width() as f32 + c.padding.left + c.padding.right;
3694 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
3695 return Some(apply_min(c, w, h));
3696 }
3697 if let Some(text) = &c.text {
3698 let content_available = match c.text_wrap {
3699 TextWrap::NoWrap => None,
3700 TextWrap::Wrap => available_width
3701 .or(match c.width {
3702 Size::Fixed(v) => Some(v),
3703 Size::Ch(n) => Some(n * ch_unit(c)),
3704 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3708 })
3709 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3710 };
3711 let display = display_text_for_measure(c, text, content_available);
3712 let layout = text_metrics::layout_text_with_line_height_and_family(
3713 &display,
3714 c.font_size,
3715 c.line_height,
3716 c.font_family,
3717 c.font_weight,
3718 c.font_mono,
3719 c.text_tabular_numerals,
3720 c.text_letter_spacing,
3721 c.text_wrap,
3722 content_available,
3723 );
3724 let w = match (content_available, c.width) {
3725 (Some(available), Size::Hug | Size::Aspect(_)) => {
3726 let unwrapped = text_metrics::layout_text_with_family(
3727 text,
3728 c.font_size,
3729 c.font_family,
3730 c.font_weight,
3731 c.font_mono,
3732 c.text_tabular_numerals,
3733 TextWrap::NoWrap,
3734 None,
3735 );
3736 unwrapped.width.min(available) + c.padding.left + c.padding.right
3737 }
3738 (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3739 available + c.padding.left + c.padding.right
3740 }
3741 (None, _) => layout.width + c.padding.left + c.padding.right,
3742 };
3743 let h = layout.height + c.padding.top + c.padding.bottom;
3744 return Some(apply_min(c, w, h));
3745 }
3746 None
3747}
3748
3749fn container_intrinsic(c: &El, available_width: Option<f32>) -> (f32, f32) {
3753 match c.axis {
3754 Axis::Overlay => {
3755 let mut w: f32 = 0.0;
3756 let mut h: f32 = 0.0;
3757 for ch in &c.children {
3758 let child_available =
3759 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3760 let (cw, chh) = intrinsic_constrained(ch, child_available);
3761 w = w.max(cw);
3762 h = h.max(chh);
3763 }
3764 apply_min(
3765 c,
3766 w + c.padding.left + c.padding.right,
3767 h + c.padding.top + c.padding.bottom,
3768 )
3769 }
3770 Axis::Column => {
3771 let mut w: f32 = 0.0;
3772 let mut h: f32 = c.padding.top + c.padding.bottom;
3773 let n = c.children.len();
3774 let child_available =
3775 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3776 for (i, ch) in c.children.iter().enumerate() {
3777 let (cw, chh) = intrinsic_constrained(ch, child_available);
3778 w = w.max(cw);
3779 h += chh;
3780 if i + 1 < n {
3781 h += c.gap;
3782 }
3783 }
3784 apply_min(c, w + c.padding.left + c.padding.right, h)
3785 }
3786 Axis::Row => {
3787 let n = c.children.len();
3797 let total_gap = c.gap * n.saturating_sub(1) as f32;
3798 let inner_available = available_width
3799 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
3800
3801 let mut consumed: f32 = 0.0;
3807 let mut fill_weight_total: f32 = 0.0;
3808 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
3809 for ch in &c.children {
3810 match ch.width {
3811 Size::Fill(w) => {
3812 fill_weight_total += w.max(0.001);
3813 sizes.push(None);
3814 }
3815 _ => {
3816 let (cw, chh) = intrinsic(ch);
3817 consumed += cw;
3818 sizes.push(Some((cw, chh)));
3819 }
3820 }
3821 }
3822
3823 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3831 let mut w_total: f32 = c.padding.left + c.padding.right;
3832 let mut h_max: f32 = 0.0;
3833 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
3834 let (cw, chh) = match slot {
3835 Some(rc) => rc,
3836 None => match (fill_remaining, fill_weight_total > 0.0) {
3837 (Some(av), true) => {
3838 let weight = match ch.width {
3839 Size::Fill(w) => w.max(0.001),
3840 _ => 1.0,
3841 };
3842 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
3843 }
3844 _ => intrinsic(ch),
3845 },
3846 };
3847 w_total += cw;
3848 if i + 1 < n {
3849 w_total += c.gap;
3850 }
3851 h_max = h_max.max(chh);
3852 }
3853 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
3854 }
3855 }
3856}
3857
3858pub(crate) fn text_layout(
3859 c: &El,
3860 available_width: Option<f32>,
3861) -> Option<text_metrics::TextLayout> {
3862 let text = c.text.as_ref()?;
3863 let content_available = match c.text_wrap {
3864 TextWrap::NoWrap => None,
3865 TextWrap::Wrap => available_width
3866 .or(match c.width {
3867 Size::Fixed(v) => Some(v),
3868 Size::Ch(n) => Some(n * ch_unit(c)),
3869 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3870 })
3871 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3872 };
3873 let display = display_text_for_measure(c, text, content_available);
3874 Some(text_metrics::layout_text_with_line_height_and_family(
3875 &display,
3876 c.font_size,
3877 c.line_height,
3878 c.font_family,
3879 c.font_weight,
3880 c.font_mono,
3881 c.text_tabular_numerals,
3882 c.text_letter_spacing,
3883 c.text_wrap,
3884 content_available,
3885 ))
3886}
3887
3888fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
3889 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
3890 (c.text_wrap, c.text_max_lines, available_width)
3891 {
3892 text_metrics::clamp_text_to_lines_with_family(
3893 text,
3894 c.font_size,
3895 c.font_family,
3896 c.font_weight,
3897 c.font_mono,
3898 width,
3899 max_lines.get() as usize,
3900 )
3901 } else {
3902 text.to_string()
3903 }
3904}
3905
3906fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
3907 if let Size::Fixed(v) = c.width {
3908 w = v;
3909 }
3910 if let Size::Fixed(v) = c.height {
3911 h = v;
3912 }
3913 (clamp_w(c, w), clamp_h(c, h))
3914}
3915
3916pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
3922 if let Some(max_w) = c.max_width {
3923 w = w.min(max_w);
3924 }
3925 if let Some(min_w) = c.min_width {
3926 w = w.max(min_w);
3927 }
3928 w.max(0.0)
3929}
3930
3931pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
3933 if let Some(max_h) = c.max_height {
3934 h = h.min(max_h);
3935 }
3936 if let Some(min_h) = c.min_height {
3937 h = h.max(min_h);
3938 }
3939 h.max(0.0)
3940}
3941
3942fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3955 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
3956 return inline_mixed_intrinsic(node, available_width);
3957 }
3958 let concat = concat_inline_text(&node.children);
3959 let size = inline_paragraph_size(node);
3960 let line_height = inline_paragraph_line_height(node);
3961 let content_available = match node.text_wrap {
3962 TextWrap::NoWrap => None,
3963 TextWrap::Wrap => available_width
3964 .or(match node.width {
3965 Size::Fixed(v) => Some(v),
3966 Size::Ch(n) => Some(n * ch_unit(node)),
3967 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3968 })
3969 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
3970 };
3971 let layout = text_metrics::layout_text_with_line_height_and_family(
3972 &concat,
3973 size,
3974 line_height,
3975 node.font_family,
3976 FontWeight::Regular,
3977 false,
3978 false,
3979 0.0,
3980 node.text_wrap,
3981 content_available,
3982 );
3983 let w = match (content_available, node.width) {
3984 (Some(available), Size::Hug | Size::Aspect(_)) => {
3985 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
3986 &concat,
3987 size,
3988 line_height,
3989 node.font_family,
3990 FontWeight::Regular,
3991 false,
3992 false,
3993 0.0,
3994 TextWrap::NoWrap,
3995 None,
3996 );
3997 unwrapped.width.min(available) + node.padding.left + node.padding.right
3998 }
3999 (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
4000 available + node.padding.left + node.padding.right
4001 }
4002 (None, _) => layout.width + node.padding.left + node.padding.right,
4003 };
4004 let h = layout.height + node.padding.top + node.padding.bottom;
4005 apply_min(node, w, h)
4006}
4007
4008fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
4009 let wrap_width = match node.text_wrap {
4010 TextWrap::Wrap => available_width.or(match node.width {
4011 Size::Fixed(v) => Some(v),
4012 Size::Ch(n) => Some(n * ch_unit(node)),
4013 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
4014 }),
4015 TextWrap::NoWrap => None,
4016 }
4017 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
4018
4019 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
4020 node.text_wrap,
4021 wrap_width,
4022 node.font_size * 0.82,
4023 node.font_size * 0.22,
4024 node.line_height,
4025 );
4026
4027 for child in &node.children {
4028 match child.kind {
4029 Kind::HardBreak => {
4030 breaker.finish_line();
4031 continue;
4032 }
4033 Kind::Text => {
4034 let text = child.text.as_deref().unwrap_or("");
4035 for chunk in inline_text_chunks(text) {
4036 let is_space = chunk.chars().all(char::is_whitespace);
4037 if breaker.skips_leading_space(is_space) {
4038 continue;
4039 }
4040 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
4041 if breaker.wraps_before(is_space, w) {
4042 breaker.finish_line();
4043 }
4044 if breaker.skips_overflowing_space(is_space, w) {
4045 continue;
4046 }
4047 breaker.push(w, ascent, descent);
4048 }
4049 continue;
4050 }
4051 _ => {}
4052 }
4053 let (w, ascent, descent) = inline_child_metrics(child);
4054 if breaker.wraps_before(false, w) {
4055 breaker.finish_line();
4056 }
4057 breaker.push(w, ascent, descent);
4058 }
4059 let measurement = breaker.finish();
4060 let w = measurement.width + node.padding.left + node.padding.right;
4061 let h = measurement.height + node.padding.top + node.padding.bottom;
4062 apply_min(node, w, h)
4063}
4064
4065fn inline_text_chunks(text: &str) -> Vec<&str> {
4066 let mut chunks = Vec::new();
4067 let mut start = 0;
4068 let mut last_space = None;
4069 for (i, ch) in text.char_indices() {
4070 let is_space = ch.is_whitespace();
4071 match last_space {
4072 None => last_space = Some(is_space),
4073 Some(prev) if prev != is_space => {
4074 chunks.push(&text[start..i]);
4075 start = i;
4076 last_space = Some(is_space);
4077 }
4078 _ => {}
4079 }
4080 }
4081 if start < text.len() {
4082 chunks.push(&text[start..]);
4083 }
4084 chunks
4085}
4086
4087fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
4088 let layout = text_metrics::layout_text_with_line_height_and_family(
4089 text,
4090 child.font_size,
4091 child.line_height,
4092 child.font_family,
4093 child.font_weight,
4094 child.font_mono,
4095 child.text_tabular_numerals,
4096 child.text_letter_spacing,
4097 TextWrap::NoWrap,
4098 None,
4099 );
4100 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
4101}
4102
4103fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
4104 match child.kind {
4105 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
4106 Kind::Math => {
4107 if let Some(expr) = &child.math {
4108 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
4109 (layout.width, layout.ascent, layout.descent)
4110 } else {
4111 (0.0, 0.0, 0.0)
4112 }
4113 }
4114 _ => (0.0, 0.0, 0.0),
4115 }
4116}
4117
4118fn concat_inline_text(children: &[El]) -> String {
4125 let mut s = String::new();
4126 for c in children {
4127 match c.kind {
4128 Kind::Text => {
4129 if let Some(t) = &c.text {
4130 s.push_str(t);
4131 }
4132 }
4133 Kind::HardBreak => s.push('\n'),
4134 _ => {}
4135 }
4136 }
4137 s
4138}
4139
4140fn inline_paragraph_size(node: &El) -> f32 {
4144 let mut size: f32 = node.font_size;
4145 for c in &node.children {
4146 if matches!(c.kind, Kind::Text) {
4147 size = size.max(c.font_size);
4148 }
4149 }
4150 size
4151}
4152
4153fn inline_paragraph_line_height(node: &El) -> f32 {
4154 let mut line_height: f32 = node.line_height;
4155 let mut max_size: f32 = node.font_size;
4156 for c in &node.children {
4157 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
4158 max_size = c.font_size;
4159 line_height = c.line_height;
4160 }
4161 }
4162 line_height
4163}
4164
4165#[cfg(test)]
4166mod tests {
4167 use super::*;
4168 use crate::state::UiState;
4169
4170 #[test]
4175 fn duplicate_sibling_keys_flagged_once() {
4176 let mut root = column([
4177 crate::widgets::text::text("a").key("dup"),
4178 crate::widgets::text::text("b").key("dup"),
4179 ]);
4180 let mut state = UiState::new();
4181 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4182
4183 assert_eq!(root.children[0].computed_id, root.children[1].computed_id);
4185 let id = root.children[0].computed_id.clone();
4186 assert!(state.layout.warned_duplicate_ids.contains(&*id));
4188
4189 let n_before = state.layout.warned_duplicate_ids.len();
4191 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4192 assert_eq!(state.layout.warned_duplicate_ids.len(), n_before);
4193 }
4194
4195 #[test]
4197 fn distinct_sibling_keys_not_flagged() {
4198 let mut root = column([
4199 crate::widgets::text::text("a").key("one"),
4200 crate::widgets::text::text("b").key("two"),
4201 ]);
4202 let mut state = UiState::new();
4203 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4204 assert!(state.layout.warned_duplicate_ids.is_empty());
4205 }
4206
4207 #[test]
4212 fn align_center_shrinks_fill_child_to_intrinsic() {
4213 let mut root = column([crate::row([crate::widgets::text::text("hi")
4217 .width(Size::Fixed(40.0))
4218 .height(Size::Fixed(20.0))])])
4219 .align(Align::Center)
4220 .width(Size::Fixed(200.0))
4221 .height(Size::Fixed(100.0));
4222 let mut state = UiState::new();
4223 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4224 let row_rect = root.children[0].computed_rect;
4225 assert!(
4228 (row_rect.x - 80.0).abs() < 0.5,
4229 "expected x≈80 (centered), got {}",
4230 row_rect.x
4231 );
4232 assert!(
4233 (row_rect.w - 40.0).abs() < 0.5,
4234 "expected w≈40 (shrunk to intrinsic), got {}",
4235 row_rect.w
4236 );
4237 }
4238
4239 #[test]
4243 fn max_clamped_fill_frees_space_to_sibling_fills() {
4244 let mut root = crate::row([
4245 El::new(Kind::Group).width(Size::Fill(1.0)).max_width(50.0),
4246 El::new(Kind::Group).width(Size::Fill(1.0)),
4247 ])
4248 .width(Size::Fixed(400.0))
4249 .height(Size::Fixed(100.0));
4250 let mut state = UiState::new();
4251 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4252 let a = root.children[0].computed_rect;
4253 let b = root.children[1].computed_rect;
4254 assert!((a.w - 50.0).abs() < 0.5, "capped fill w={}", a.w);
4255 assert!(
4256 (b.w - 350.0).abs() < 0.5,
4257 "sibling fill should absorb the freed 150px, got w={}",
4258 b.w
4259 );
4260 }
4261
4262 #[test]
4266 fn min_clamped_fill_steals_space_from_sibling_fills() {
4267 let mut root = crate::row([
4268 El::new(Kind::Group).width(Size::Fill(1.0)).min_width(300.0),
4269 El::new(Kind::Group).width(Size::Fill(1.0)),
4270 ])
4271 .width(Size::Fixed(400.0))
4272 .height(Size::Fixed(100.0));
4273 let mut state = UiState::new();
4274 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4275 let a = root.children[0].computed_rect;
4276 let b = root.children[1].computed_rect;
4277 assert!((a.w - 300.0).abs() < 0.5, "floored fill w={}", a.w);
4278 assert!(
4279 (b.w - 100.0).abs() < 0.5,
4280 "sibling fill should shrink to the remaining 100px, got w={}",
4281 b.w
4282 );
4283 }
4284
4285 #[test]
4289 fn justify_distributes_space_left_by_fully_capped_fills() {
4290 let mut root = crate::row([El::new(Kind::Group).width(Size::Fill(1.0)).max_width(100.0)])
4291 .justify(Justify::Center)
4292 .width(Size::Fixed(400.0))
4293 .height(Size::Fixed(100.0));
4294 let mut state = UiState::new();
4295 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4296 let a = root.children[0].computed_rect;
4297 assert!((a.w - 100.0).abs() < 0.5, "capped fill w={}", a.w);
4298 assert!(
4299 (a.x - 150.0).abs() < 0.5,
4300 "capped fill should be centered (x≈150), got x={}",
4301 a.x
4302 );
4303 }
4304
4305 #[test]
4308 fn align_stretch_preserves_fill_stretch() {
4309 let mut root = column([crate::row([crate::widgets::text::text("hi")
4310 .width(Size::Fixed(40.0))
4311 .height(Size::Fixed(20.0))])])
4312 .align(Align::Stretch)
4313 .width(Size::Fixed(200.0))
4314 .height(Size::Fixed(100.0));
4315 let mut state = UiState::new();
4316 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4317 let row_rect = root.children[0].computed_rect;
4318 assert!(
4319 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
4320 "expected stretched (x=0, w=200), got x={} w={}",
4321 row_rect.x,
4322 row_rect.w
4323 );
4324 }
4325
4326 #[test]
4333 fn children_of_text_bearing_node_still_get_sized() {
4334 let mut root = column([El::new(Kind::Group)
4335 .text("label".to_string())
4336 .child(
4337 crate::widgets::text::text("nested child")
4338 .width(Size::Hug)
4339 .height(Size::Hug),
4340 )
4341 .width(Size::Fixed(300.0))
4342 .height(Size::Fixed(100.0))]);
4343 let mut state = UiState::new();
4344 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 100.0));
4345 let child = root.children[0].children[0].computed_rect;
4346 assert!(
4347 child.w > 10.0 && child.h > 5.0,
4348 "hug child of a text-bearing parent must size from its own \
4349 content, got {child:?}"
4350 );
4351 }
4352
4353 #[test]
4360 fn clamped_fill_resizes_wrap_text_descendants_at_final_width() {
4361 let long = "words that will definitely wrap at two hundred pixels \
4362 but not at three hundred, repeated for effect and \
4363 measure, wrapping across several lines";
4364 let make = |max_w: Option<f32>| {
4365 let mut fill = column([crate::widgets::text::text(long).wrap_text()])
4366 .width(Size::Fill(1.0))
4367 .height(Size::Hug);
4368 if let Some(m) = max_w {
4369 fill.max_width = Some(m);
4370 }
4371 crate::row([
4372 crate::tree::spacer()
4373 .width(Size::Fixed(100.0))
4374 .height(Size::Fixed(10.0)),
4375 fill,
4376 ])
4377 .width(Size::Fixed(400.0))
4378 .height(Size::Fixed(400.0))
4379 };
4380 let mut clamped = make(Some(200.0));
4383 let mut state = UiState::new();
4384 layout(&mut clamped, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4385 let col = &clamped.children[1];
4386 let col_rect = col.computed_rect;
4387 assert!(
4388 (col_rect.w - 200.0).abs() < 0.5,
4389 "fill should clamp to 200, got {}",
4390 col_rect.w
4391 );
4392 let para = col.children[0].computed_rect;
4396 let (_, expected_h) = intrinsic_constrained(&col.children[0], Some(200.0));
4397 assert!(
4398 (para.h - expected_h).abs() < 0.5,
4399 "paragraph should reserve wrap-at-200 height {expected_h}, got {}",
4400 para.h
4401 );
4402 }
4403
4404 #[test]
4411 fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
4412 let long = "The quick brown fox jumps over the lazy dog, then \
4413 does it again and again until the line is long \
4414 enough to wrap several times.";
4415 let mut root = column([column([column([crate::widgets::text::text(long)
4419 .wrap_text()
4420 .width(Size::Fixed(200.0))])
4421 .width(Size::Fixed(240.0))])
4422 .width(Size::Fill(1.0))]);
4423 let mut state = UiState::new();
4424 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4425 let card = root.children[0].computed_rect;
4426 let text_rect = root.children[0].children[0].children[0].computed_rect;
4427 assert!(
4428 text_rect.h > 25.0,
4429 "text should wrap to multiple lines at 200px, got h={}",
4430 text_rect.h
4431 );
4432 assert!(
4433 (card.h - text_rect.h).abs() < 0.5,
4434 "Hug card height {} must match wrapped text height {}",
4435 card.h,
4436 text_rect.h
4437 );
4438 }
4439
4440 #[test]
4443 fn justify_center_centers_hug_children() {
4444 let mut root = column([crate::widgets::text::text("hi")
4445 .width(Size::Fixed(40.0))
4446 .height(Size::Fixed(20.0))])
4447 .justify(Justify::Center)
4448 .height(Size::Fill(1.0));
4449 let mut state = UiState::new();
4450 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4451 let child_rect = root.children[0].computed_rect;
4452 assert!(
4454 (child_rect.y - 40.0).abs() < 0.5,
4455 "expected y≈40, got {}",
4456 child_rect.y
4457 );
4458 }
4459
4460 #[test]
4461 fn justify_end_pushes_to_bottom() {
4462 let mut root = column([crate::widgets::text::text("hi")
4463 .width(Size::Fixed(40.0))
4464 .height(Size::Fixed(20.0))])
4465 .justify(Justify::End)
4466 .height(Size::Fill(1.0));
4467 let mut state = UiState::new();
4468 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4469 let child_rect = root.children[0].computed_rect;
4470 assert!(
4471 (child_rect.y - 80.0).abs() < 0.5,
4472 "expected y≈80, got {}",
4473 child_rect.y
4474 );
4475 }
4476
4477 #[test]
4481 fn justify_space_between_distributes_evenly() {
4482 let row_child = || {
4483 crate::widgets::text::text("x")
4484 .width(Size::Fixed(20.0))
4485 .height(Size::Fixed(20.0))
4486 };
4487 let mut root = column([row_child(), row_child(), row_child()])
4488 .justify(Justify::SpaceBetween)
4489 .height(Size::Fixed(200.0));
4490 let mut state = UiState::new();
4491 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
4492 let y0 = root.children[0].computed_rect.y;
4495 let y1 = root.children[1].computed_rect.y;
4496 let y2 = root.children[2].computed_rect.y;
4497 assert!(
4498 y0.abs() < 0.5,
4499 "first child should be flush at y=0, got {y0}"
4500 );
4501 assert!(
4502 (y1 - 90.0).abs() < 0.5,
4503 "middle child should be at y≈90, got {y1}"
4504 );
4505 assert!(
4506 (y2 - 180.0).abs() < 0.5,
4507 "last child should be flush at y≈180, got {y2}"
4508 );
4509 }
4510
4511 #[test]
4515 fn fill_weight_distributes_proportionally() {
4516 let big = crate::widgets::text::text("big")
4517 .width(Size::Fixed(40.0))
4518 .height(Size::Fill(2.0));
4519 let small = crate::widgets::text::text("small")
4520 .width(Size::Fixed(40.0))
4521 .height(Size::Fill(1.0));
4522 let mut root = column([big, small]).height(Size::Fixed(300.0));
4523 let mut state = UiState::new();
4524 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
4525 let big_h = root.children[0].computed_rect.h;
4527 let small_h = root.children[1].computed_rect.h;
4528 assert!(
4529 (big_h - 200.0).abs() < 0.5,
4530 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
4531 );
4532 assert!(
4533 (small_h - 100.0).abs() < 0.5,
4534 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
4535 );
4536 }
4537
4538 #[test]
4542 fn padding_on_hug_includes_in_intrinsic() {
4543 let root = column([crate::widgets::text::text("x")
4544 .width(Size::Fixed(40.0))
4545 .height(Size::Fixed(40.0))])
4546 .padding(Sides::all(20.0));
4547 let (w, h) = intrinsic(&root);
4548 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
4550 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
4551 }
4552
4553 #[test]
4557 fn align_end_pins_to_cross_axis_far_edge() {
4558 let mut root = crate::row([crate::widgets::text::text("hi")
4559 .width(Size::Fixed(40.0))
4560 .height(Size::Fixed(20.0))])
4561 .align(Align::End)
4562 .width(Size::Fixed(200.0))
4563 .height(Size::Fixed(100.0));
4564 let mut state = UiState::new();
4565 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4566 let child_rect = root.children[0].computed_rect;
4567 assert!(
4569 (child_rect.y - 80.0).abs() < 0.5,
4570 "expected y≈80 (pinned to bottom), got {}",
4571 child_rect.y
4572 );
4573 }
4574
4575 #[test]
4576 fn overlay_can_center_hug_child() {
4577 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
4578 .width(Size::Fixed(200.0))
4579 .height(Size::Hug)])
4580 .align(Align::Center)
4581 .justify(Justify::Center);
4582 let mut state = UiState::new();
4583 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
4584 let child_rect = root.children[0].computed_rect;
4585 assert!(
4586 (child_rect.x - 200.0).abs() < 0.5,
4587 "expected x≈200, got {}",
4588 child_rect.x
4589 );
4590 assert!(
4591 child_rect.y > 100.0 && child_rect.y < 200.0,
4592 "expected centered y, got {}",
4593 child_rect.y
4594 );
4595 }
4596
4597 #[test]
4598 fn scroll_offset_translates_children_and_clamps_to_content() {
4599 let mut root = scroll(
4603 (0..6)
4604 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4605 )
4606 .key("list")
4607 .gap(12.0)
4608 .height(Size::Fixed(200.0));
4609 let mut state = UiState::new();
4610 assign_ids(&mut root);
4611 state
4612 .scroll
4613 .offsets
4614 .insert(root.computed_id.clone().to_string(), 80.0);
4615
4616 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4617
4618 let stored = state
4620 .scroll
4621 .offsets
4622 .get(&*root.computed_id)
4623 .copied()
4624 .unwrap_or(0.0);
4625 assert!(
4626 (stored - 80.0).abs() < 0.01,
4627 "offset clamped unexpectedly: {stored}"
4628 );
4629 let c0 = root.children[0].computed_rect;
4631 assert!(
4632 (c0.y - (-80.0)).abs() < 0.01,
4633 "child 0 y = {} (expected -80)",
4634 c0.y
4635 );
4636 state
4638 .scroll
4639 .offsets
4640 .insert(root.computed_id.clone().to_string(), 9999.0);
4641 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4642 let stored = state
4643 .scroll
4644 .offsets
4645 .get(&*root.computed_id)
4646 .copied()
4647 .unwrap_or(0.0);
4648 assert!(
4649 (stored - 160.0).abs() < 0.01,
4650 "overshoot clamped to {stored}"
4651 );
4652 let mut tiny =
4654 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
4655 .height(Size::Fixed(200.0));
4656 let mut tiny_state = UiState::new();
4657 assign_ids(&mut tiny);
4658 tiny_state
4659 .scroll
4660 .offsets
4661 .insert(tiny.computed_id.clone().to_string(), 50.0);
4662 layout(
4663 &mut tiny,
4664 &mut tiny_state,
4665 Rect::new(0.0, 0.0, 300.0, 200.0),
4666 );
4667 assert_eq!(
4668 tiny_state
4669 .scroll
4670 .offsets
4671 .get(&*tiny.computed_id)
4672 .copied()
4673 .unwrap_or(0.0),
4674 0.0
4675 );
4676 }
4677
4678 #[test]
4679 fn scroll_layout_prunes_far_offscreen_descendants() {
4680 let far = column([crate::widgets::text::text("far row body").key("far-text")])
4681 .height(Size::Fixed(40.0));
4682 let mut root = scroll([
4683 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
4684 crate::tree::spacer().height(Size::Fixed(400.0)),
4685 far,
4686 ])
4687 .key("list")
4688 .height(Size::Fixed(80.0));
4689 let mut state = UiState::new();
4690 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
4691 let stats = take_prune_stats();
4692
4693 assert!(
4694 stats.subtrees >= 1,
4695 "expected at least one far scroll child to be pruned, got {stats:?}"
4696 );
4697 assert!(
4698 stats.nodes >= 1,
4699 "expected pruned descendants to be zeroed, got {stats:?}"
4700 );
4701 let far_text = state
4702 .rect_of_key("far-text")
4703 .expect("far text keeps a zero rect while pruned");
4704 assert_eq!(far_text.w, 0.0);
4705 assert_eq!(far_text.h, 0.0);
4706 }
4707
4708 #[test]
4709 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
4710 let make_root = || {
4711 let paragraph_text = "Variable width text wraps into a different number of lines when \
4712 the viewport narrows, which used to make a plain scroll box lose \
4713 the item the user was reading.";
4714 scroll([column((0..30).map(|i| {
4715 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
4716 .key(format!("paragraph-{i}"))
4717 }))
4718 .gap(8.0)])
4719 .key("article")
4720 .height(Size::Fixed(180.0))
4721 };
4722
4723 let mut root = make_root();
4724 let mut state = UiState::new();
4725 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4726
4727 state
4728 .scroll
4729 .offsets
4730 .insert(root.computed_id.clone().to_string(), 520.0);
4731 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4732
4733 let anchor = state
4734 .scroll
4735 .scroll_anchors
4736 .get(&*root.computed_id)
4737 .cloned()
4738 .expect("plain scroll should store a visible descendant anchor");
4739 let before_rect = find_descendant_rect(&root, &anchor.node_id).expect("anchor rect before");
4740 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
4741 let before_offset = state.scroll_offset(&root.computed_id);
4742
4743 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
4744
4745 let after_rect = find_descendant_rect(&root, &anchor.node_id).expect("anchor rect after");
4746 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
4747 let after_offset = state.scroll_offset(&root.computed_id);
4748 assert!(
4749 (after_anchor_y - before_anchor_y).abs() < 0.5,
4750 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
4751 );
4752 assert!(
4753 (after_offset - before_offset).abs() > 20.0,
4754 "offset should absorb height changes above the anchor"
4755 );
4756 }
4757
4758 #[test]
4759 fn scrollbar_thumb_size_and_position_track_overflow() {
4760 let mut root = scroll(
4763 (0..6)
4764 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4765 )
4766 .gap(12.0)
4767 .height(Size::Fixed(200.0));
4768 let mut state = UiState::new();
4769 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4770
4771 let metrics = state
4772 .scroll
4773 .metrics
4774 .get(&*root.computed_id)
4775 .copied()
4776 .expect("scrollable should have metrics");
4777 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
4778 assert!((metrics.content_h - 360.0).abs() < 0.01);
4779 assert!((metrics.max_offset - 160.0).abs() < 0.01);
4780
4781 let thumb = state
4782 .scroll
4783 .thumb_rects
4784 .get(&*root.computed_id)
4785 .copied()
4786 .expect("scrollable with scrollbar() and overflow gets a thumb");
4787 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
4789 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
4790 assert!(thumb.y.abs() < 0.01);
4792 assert!(
4794 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
4795 "thumb anchored at {} (expected {})",
4796 thumb.x,
4797 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
4798 );
4799
4800 state
4802 .scroll
4803 .offsets
4804 .insert(root.computed_id.clone().to_string(), 80.0);
4805 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4806 let thumb = state
4807 .scroll
4808 .thumb_rects
4809 .get(&*root.computed_id)
4810 .copied()
4811 .unwrap();
4812 let track_remaining = 200.0 - thumb.h;
4813 let expected_y = track_remaining * (80.0 / 160.0);
4814 assert!(
4815 (thumb.y - expected_y).abs() < 0.5,
4816 "thumb at half-scroll y = {} (expected {expected_y})",
4817 thumb.y,
4818 );
4819 }
4820
4821 #[test]
4822 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
4823 let mut root = scroll(
4827 (0..6)
4828 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4829 )
4830 .gap(12.0)
4831 .height(Size::Fixed(200.0));
4832 let mut state = UiState::new();
4833 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4834
4835 let thumb = state
4836 .scroll
4837 .thumb_rects
4838 .get(&*root.computed_id)
4839 .copied()
4840 .unwrap();
4841 let track = state
4842 .scroll
4843 .thumb_tracks
4844 .get(&*root.computed_id)
4845 .copied()
4846 .unwrap();
4847 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
4849 assert!(
4850 (track.right() - thumb.right()).abs() < 0.01,
4851 "track and thumb must share the right edge",
4852 );
4853 assert!(
4856 (track.h - 200.0).abs() < 0.01,
4857 "track height = {} (expected 200)",
4858 track.h,
4859 );
4860 }
4861
4862 #[test]
4863 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
4864 let mut suppressed = scroll(
4866 (0..6)
4867 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4868 )
4869 .no_scrollbar()
4870 .height(Size::Fixed(200.0));
4871 let mut state = UiState::new();
4872 layout(
4873 &mut suppressed,
4874 &mut state,
4875 Rect::new(0.0, 0.0, 300.0, 200.0),
4876 );
4877 assert!(
4878 !state
4879 .scroll
4880 .thumb_rects
4881 .contains_key(&*suppressed.computed_id)
4882 );
4883
4884 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
4886 .height(Size::Fixed(200.0));
4887 let mut tiny_state = UiState::new();
4888 layout(
4889 &mut tiny,
4890 &mut tiny_state,
4891 Rect::new(0.0, 0.0, 300.0, 200.0),
4892 );
4893 assert!(
4894 !tiny_state
4895 .scroll
4896 .thumb_rects
4897 .contains_key(&*tiny.computed_id)
4898 );
4899 }
4900
4901 #[test]
4902 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
4903 let make_root = || {
4904 scroll([
4905 crate::tree::spacer().height(Size::Fixed(80.0)),
4906 scroll((0..6).map(|i| {
4907 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
4908 }))
4909 .key("inner")
4910 .height(Size::Fixed(120.0)),
4911 crate::tree::spacer().height(Size::Fixed(260.0)),
4912 ])
4913 .key("outer")
4914 .height(Size::Fixed(220.0))
4915 };
4916
4917 let mut root = make_root();
4918 let mut state = UiState::new();
4919 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4920 let inner = root
4921 .children
4922 .iter()
4923 .find(|child| child.key.as_deref() == Some("inner"))
4924 .expect("inner scroll");
4925 let inner_id = inner.computed_id.clone();
4926 let inner_rect = state.rect(&inner_id);
4927 let thumb = state
4928 .scroll
4929 .thumb_rects
4930 .get(&*inner_id)
4931 .copied()
4932 .expect("inner scroll should have a thumb");
4933 let track = state
4934 .scroll
4935 .thumb_tracks
4936 .get(&*inner_id)
4937 .copied()
4938 .expect("inner scroll should have a track");
4939 let thumb_rel_y = thumb.y - inner_rect.y;
4940 let track_rel_y = track.y - inner_rect.y;
4941
4942 state
4943 .scroll
4944 .offsets
4945 .insert(root.computed_id.clone().to_string(), 60.0);
4946 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4947 let inner_rect_after = state.rect(&inner_id);
4948 let thumb_after = state.scroll.thumb_rects.get(&*inner_id).copied().unwrap();
4949 let track_after = state.scroll.thumb_tracks.get(&*inner_id).copied().unwrap();
4950
4951 assert!(
4952 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
4953 "outer scroll should shift the inner viewport"
4954 );
4955 assert!(
4956 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
4957 "inner thumb should stay fixed relative to its viewport"
4958 );
4959 assert!(
4960 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
4961 "inner track should stay fixed relative to its viewport"
4962 );
4963 }
4964
4965 #[test]
4966 fn layout_override_places_children_at_returned_rects() {
4967 let mut root = column((0..3).map(|i| {
4969 crate::widgets::text::text(format!("dot {i}"))
4970 .width(Size::Fixed(20.0))
4971 .height(Size::Fixed(20.0))
4972 }))
4973 .width(Size::Fixed(200.0))
4974 .height(Size::Fixed(200.0))
4975 .layout(|ctx| {
4976 ctx.children
4977 .iter()
4978 .enumerate()
4979 .map(|(i, _)| {
4980 let off = i as f32 * 30.0;
4981 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
4982 })
4983 .collect()
4984 });
4985 let mut state = UiState::new();
4986 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4987 let r0 = root.children[0].computed_rect;
4988 let r1 = root.children[1].computed_rect;
4989 let r2 = root.children[2].computed_rect;
4990 assert_eq!((r0.x, r0.y), (0.0, 0.0));
4991 assert_eq!((r1.x, r1.y), (30.0, 30.0));
4992 assert_eq!((r2.x, r2.y), (60.0, 60.0));
4993 }
4994
4995 #[test]
4996 fn layout_override_rect_of_key_resolves_earlier_sibling() {
4997 use crate::tree::stack;
5003 let trigger_x = 40.0;
5004 let trigger_y = 20.0;
5005 let trigger_w = 60.0;
5006 let trigger_h = 30.0;
5007 let mut root = stack([
5008 crate::widgets::button::button("Open")
5010 .key("trig")
5011 .width(Size::Fixed(trigger_w))
5012 .height(Size::Fixed(trigger_h)),
5013 stack([crate::widgets::text::text("popover")
5016 .width(Size::Fixed(80.0))
5017 .height(Size::Fixed(20.0))])
5018 .width(Size::Fill(1.0))
5019 .height(Size::Fill(1.0))
5020 .layout(|ctx| {
5021 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
5022 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
5023 }),
5024 ])
5025 .padding(Sides::xy(trigger_x, trigger_y));
5026 let mut state = UiState::new();
5027 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5028
5029 let popover_layer = &root.children[1];
5030 let panel_rect = popover_layer.children[0].computed_rect;
5031 assert!(
5034 (panel_rect.x - trigger_x).abs() < 0.01,
5035 "popover x = {} (expected {trigger_x})",
5036 panel_rect.x,
5037 );
5038 assert!(
5039 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
5040 "popover y = {} (expected {})",
5041 panel_rect.y,
5042 trigger_y + trigger_h + 4.0,
5043 );
5044 }
5045
5046 #[test]
5047 fn layout_override_rect_of_key_returns_none_for_missing_key() {
5048 let mut root = column([crate::widgets::text::text("inner")
5049 .width(Size::Fixed(40.0))
5050 .height(Size::Fixed(20.0))])
5051 .width(Size::Fixed(200.0))
5052 .height(Size::Fixed(200.0))
5053 .layout(|ctx| {
5054 assert!((ctx.rect_of_key)("nope").is_none());
5055 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
5056 });
5057 let mut state = UiState::new();
5058 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
5059 }
5060
5061 #[test]
5062 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
5063 use crate::tree::stack;
5069 let mut root = stack([
5070 stack([crate::widgets::text::text("panel")
5071 .width(Size::Fixed(40.0))
5072 .height(Size::Fixed(20.0))])
5073 .width(Size::Fill(1.0))
5074 .height(Size::Fill(1.0))
5075 .layout(|ctx| {
5076 assert!(
5077 (ctx.rect_of_key)("later").is_none(),
5078 "later sibling's rect must not be available yet"
5079 );
5080 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
5081 }),
5082 crate::widgets::button::button("after").key("later"),
5083 ]);
5084 let mut state = UiState::new();
5085 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5086 }
5087
5088 #[test]
5089 fn layout_override_measure_returns_intrinsic() {
5090 let mut root = column([crate::widgets::text::text("hi")
5092 .width(Size::Fixed(40.0))
5093 .height(Size::Fixed(20.0))])
5094 .width(Size::Fixed(200.0))
5095 .height(Size::Fixed(200.0))
5096 .layout(|ctx| {
5097 let (w, h) = (ctx.measure)(&ctx.children[0]);
5098 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
5099 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
5100 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
5101 });
5102 let mut state = UiState::new();
5103 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
5104 let r = root.children[0].computed_rect;
5105 assert_eq!((r.w, r.h), (40.0, 20.0));
5106 }
5107
5108 #[test]
5109 #[should_panic(expected = "returned 1 rects for 2 children")]
5110 fn layout_override_length_mismatch_panics() {
5111 let mut root = column([
5112 crate::widgets::text::text("a")
5113 .width(Size::Fixed(10.0))
5114 .height(Size::Fixed(10.0)),
5115 crate::widgets::text::text("b")
5116 .width(Size::Fixed(10.0))
5117 .height(Size::Fixed(10.0)),
5118 ])
5119 .width(Size::Fixed(200.0))
5120 .height(Size::Fixed(200.0))
5121 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
5122 let mut state = UiState::new();
5123 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
5124 }
5125
5126 #[test]
5127 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
5128 fn layout_override_hug_panics() {
5129 let mut root = column([column([crate::widgets::text::text("c")])
5133 .width(Size::Hug)
5134 .height(Size::Fixed(200.0))
5135 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
5136 .width(Size::Fixed(200.0))
5137 .height(Size::Fixed(200.0));
5138 let mut state = UiState::new();
5139 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
5140 }
5141
5142 #[test]
5143 fn fit_contain_letterboxes_and_centers() {
5144 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted");
5147 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
5148 .width(Size::Fixed(400.0))
5149 .height(Size::Fixed(400.0));
5150 let mut state = UiState::new();
5151 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5152
5153 let r = state.rect_of_key("fitted").expect("fitted rect");
5154 assert_eq!((r.w, r.h), (400.0, 225.0));
5155 assert_eq!(r.x, 0.0);
5156 assert!((r.y - 87.5).abs() < 0.01, "centered: y = {}", r.y);
5157
5158 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted2");
5160 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
5161 .width(Size::Fixed(400.0))
5162 .height(Size::Fixed(100.0));
5163 let mut state = UiState::new();
5164 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
5165 let r = state.rect_of_key("fitted2").expect("fitted2 rect");
5166 assert!((r.w - 100.0 * 16.0 / 9.0).abs() < 0.01);
5167 assert_eq!(r.h, 100.0);
5168 assert!((r.x - (400.0 - r.w) / 2.0).abs() < 0.01);
5169 }
5170
5171 #[test]
5172 fn fit_cover_overflows_the_slack_axis_and_clips() {
5173 let child = crate::tree::column([crate::widgets::text::text("c")]).key("covered");
5177 let mut root = crate::tree::fit_cover(child, 1.0)
5178 .width(Size::Fixed(400.0))
5179 .height(Size::Fixed(200.0));
5180 assert!(root.clip, "fit_cover must clip its overflow");
5181 let mut state = UiState::new();
5182 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
5183 let r = state.rect_of_key("covered").expect("covered rect");
5184 assert_eq!((r.w, r.h), (400.0, 400.0));
5185 assert!(
5186 (r.y - -100.0).abs() < 0.01,
5187 "centered overflow: y = {}",
5188 r.y
5189 );
5190 }
5191
5192 #[test]
5193 fn fit_contain_intrinsic_uses_the_child_measure() {
5194 let child = crate::tree::column::<Vec<El>, El>(vec![])
5196 .width(Size::Fixed(100.0))
5197 .height(Size::Fixed(50.0))
5198 .key("intrinsic");
5199 let mut root = crate::tree::fit_contain_intrinsic(child)
5200 .width(Size::Fixed(400.0))
5201 .height(Size::Fixed(400.0));
5202 let mut state = UiState::new();
5203 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5204 let r = state.rect_of_key("intrinsic").expect("intrinsic rect");
5205 assert_eq!((r.w, r.h), (400.0, 200.0));
5206 }
5207
5208 #[test]
5209 fn virtual_list_realizes_only_visible_rows() {
5210 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
5214 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5215 });
5216 let mut state = UiState::new();
5217 assign_ids(&mut root);
5218 state
5219 .scroll
5220 .offsets
5221 .insert(root.computed_id.clone().to_string(), 120.0);
5222 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5223
5224 assert_eq!(
5225 root.children.len(),
5226 5,
5227 "expected 5 realized rows, got {}",
5228 root.children.len()
5229 );
5230 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
5232 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
5233 let r0 = root.children[0].computed_rect;
5235 assert!(
5236 (r0.y - (-20.0)).abs() < 0.5,
5237 "row 2 expected y≈-20, got {}",
5238 r0.y
5239 );
5240 }
5241
5242 #[test]
5243 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
5244 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
5245 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5246 })
5247 .gap(10.0);
5248 let mut state = UiState::new();
5249 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5250
5251 assert_eq!(
5252 root.children.len(),
5253 3,
5254 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5255 );
5256 let row_1 = root
5257 .children
5258 .iter()
5259 .find(|c| c.key.as_deref() == Some("row-1"))
5260 .expect("row 1 should be realized");
5261 assert!(
5262 (row_1.computed_rect.y - 50.0).abs() < 0.5,
5263 "gap should place row 1 at y=50"
5264 );
5265 let metrics = state
5266 .scroll
5267 .metrics
5268 .get(&*root.computed_id)
5269 .expect("virtual list writes scroll metrics");
5270 assert!(
5271 (metrics.content_h - 490.0).abs() < 0.5,
5272 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5273 metrics.content_h
5274 );
5275 }
5276
5277 #[test]
5278 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
5279 let make_root = || {
5280 crate::tree::virtual_list(50, 50.0, |i| {
5281 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5282 })
5283 };
5284
5285 let mut state = UiState::new();
5286 let mut root_a = make_root();
5287 assign_ids(&mut root_a);
5288 state
5290 .scroll
5291 .offsets
5292 .insert(root_a.computed_id.clone().to_string(), 250.0);
5293 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5294 let id_at_offset_a = root_a
5295 .children
5296 .iter()
5297 .find(|c| c.key.as_deref() == Some("row-5"))
5298 .unwrap()
5299 .computed_id
5300 .clone();
5301
5302 let mut root_b = make_root();
5304 assign_ids(&mut root_b);
5305 state
5306 .scroll
5307 .offsets
5308 .insert(root_b.computed_id.clone().to_string(), 200.0);
5309 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5310 let id_at_offset_b = root_b
5311 .children
5312 .iter()
5313 .find(|c| c.key.as_deref() == Some("row-5"))
5314 .unwrap()
5315 .computed_id
5316 .clone();
5317
5318 assert_eq!(
5319 id_at_offset_a, id_at_offset_b,
5320 "row-5's computed_id changed when scroll offset moved"
5321 );
5322 }
5323
5324 #[test]
5325 fn virtual_list_clamps_overshoot_offset() {
5326 let mut root =
5328 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
5329 let mut state = UiState::new();
5330 assign_ids(&mut root);
5331 state
5332 .scroll
5333 .offsets
5334 .insert(root.computed_id.clone().to_string(), 9999.0);
5335 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5336 let stored = state
5337 .scroll
5338 .offsets
5339 .get(&*root.computed_id)
5340 .copied()
5341 .unwrap_or(0.0);
5342 assert!(
5343 (stored - 300.0).abs() < 0.01,
5344 "expected clamp to 300, got {stored}"
5345 );
5346 }
5347
5348 #[test]
5349 fn virtual_list_empty_count_realizes_no_children() {
5350 let mut root =
5351 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
5352 let mut state = UiState::new();
5353 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5354 assert_eq!(root.children.len(), 0);
5355 }
5356
5357 #[test]
5358 #[should_panic(expected = "row_height > 0.0")]
5359 fn virtual_list_zero_row_height_panics() {
5360 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
5361 }
5362
5363 #[test]
5364 #[should_panic(expected = "Size::Hug would defeat virtualization")]
5365 fn virtual_list_hug_panics() {
5366 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
5367 crate::widgets::text::text(format!("r{i}"))
5368 })
5369 .height(Size::Hug)])
5370 .width(Size::Fixed(300.0))
5371 .height(Size::Fixed(200.0));
5372 let mut state = UiState::new();
5373 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5374 }
5375
5376 #[test]
5377 fn grid_packs_rows_and_aligns_the_partial_tail() {
5378 let cell = |i: usize| {
5382 crate::tree::column::<Vec<El>, El>(vec![])
5383 .key(format!("cell-{i}"))
5384 .height(Size::Fixed(50.0))
5385 };
5386 let mut root = crate::tree::grid(2, 10.0, (0..3).map(cell))
5387 .width(Size::Fixed(210.0))
5388 .height(Size::Fixed(300.0));
5389 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
5390 let mut state = UiState::new();
5391 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 210.0, 300.0));
5392
5393 let r0 = state.rect_of_key("cell-0").unwrap();
5394 let r1 = state.rect_of_key("cell-1").unwrap();
5395 let r2 = state.rect_of_key("cell-2").unwrap();
5396 assert_eq!((r0.x, r0.w), (0.0, 100.0));
5397 assert_eq!((r1.x, r1.w), (110.0, 100.0));
5398 assert_eq!((r2.x, r2.w), (0.0, 100.0));
5400 assert_eq!(r2.y, 60.0);
5401 }
5402
5403 #[test]
5404 fn virtual_grid_realizes_rows_of_packed_cells() {
5405 let mut root = crate::tree::virtual_grid(10, 3, 50.0, 0.0, |i| {
5408 crate::widgets::text::text(format!("item {i}")).key(format!("item-{i}"))
5409 })
5410 .key("vgrid");
5411 let mut state = UiState::new();
5412 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5413
5414 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
5415 let rect_of = |row: usize, col: usize| root.children[row].children[col].computed_rect;
5420 let i0 = rect_of(0, 0);
5422 let i2 = rect_of(0, 2);
5423 assert_eq!(i0.w, 100.0);
5424 assert_eq!(i2.x, 200.0);
5425 let i3 = rect_of(1, 0);
5427 assert_eq!((i3.x, i3.y), (0.0, 50.0));
5428 assert!(state.visible_range("vgrid").is_some());
5430 assert_eq!(root.children[0].children[0].key.as_deref(), Some("item-0"));
5432 }
5433
5434 #[test]
5435 fn visible_range_tracks_realized_rows() {
5436 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
5440 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5441 })
5442 .key("list");
5443 let mut state = UiState::new();
5444 assign_ids(&mut root);
5445 state
5446 .scroll
5447 .offsets
5448 .insert(root.computed_id.clone().to_string(), 120.0);
5449 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5450
5451 assert_eq!(state.visible_range("list"), Some(2..7));
5452 assert_eq!(state.visible_range("not-a-list"), None);
5453
5454 let mut dyn_root = crate::tree::virtual_list_dyn(
5457 20,
5458 50.0,
5459 |i| format!("row-{i}"),
5460 |i| {
5461 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
5462 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5463 .key(format!("row-{i}"))
5464 .height(Size::Fixed(h))
5465 },
5466 )
5467 .key("dyn-list");
5468 let mut dyn_state = UiState::new();
5469 layout(
5470 &mut dyn_root,
5471 &mut dyn_state,
5472 Rect::new(0.0, 0.0, 300.0, 200.0),
5473 );
5474 assert_eq!(dyn_state.visible_range("dyn-list"), Some(0..4));
5475
5476 let mut empty =
5478 crate::tree::virtual_list(0, 50.0, |_| crate::widgets::text::text("never")).key("list");
5479 layout(&mut empty, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5480 assert_eq!(state.visible_range("list"), None);
5481 }
5482
5483 #[test]
5484 fn virtual_list_dyn_respects_per_row_fixed_heights() {
5485 let mut root = crate::tree::virtual_list_dyn(
5489 20,
5490 50.0,
5491 |i| format!("row-{i}"),
5492 |i| {
5493 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
5494 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5495 .key(format!("row-{i}"))
5496 .height(Size::Fixed(h))
5497 },
5498 );
5499 let mut state = UiState::new();
5500 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5501
5502 assert_eq!(
5503 root.children.len(),
5504 4,
5505 "expected 4 realized rows, got {}",
5506 root.children.len()
5507 );
5508 let ys: Vec<f32> = root.children.iter().map(|c| c.computed_rect.y).collect();
5510 assert!(
5511 (ys[0] - 0.0).abs() < 0.5,
5512 "row 0 expected y≈0, got {}",
5513 ys[0]
5514 );
5515 assert!(
5516 (ys[1] - 40.0).abs() < 0.5,
5517 "row 1 expected y≈40, got {}",
5518 ys[1]
5519 );
5520 assert!(
5521 (ys[2] - 120.0).abs() < 0.5,
5522 "row 2 expected y≈120, got {}",
5523 ys[2]
5524 );
5525 assert!(
5526 (ys[3] - 160.0).abs() < 0.5,
5527 "row 3 expected y≈160, got {}",
5528 ys[3]
5529 );
5530 }
5531
5532 #[test]
5533 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
5534 let mut root = crate::tree::virtual_list_dyn(
5535 10,
5536 40.0,
5537 |i| format!("row-{i}"),
5538 |i| {
5539 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
5540 .key(format!("row-{i}"))
5541 .height(Size::Fixed(40.0))
5542 },
5543 )
5544 .gap(10.0);
5545 let mut state = UiState::new();
5546 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5547
5548 assert_eq!(
5549 root.children.len(),
5550 3,
5551 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5552 );
5553 let row_1 = root
5554 .children
5555 .iter()
5556 .find(|c| c.key.as_deref() == Some("row-1"))
5557 .expect("row 1 should be realized");
5558 assert!(
5559 (row_1.computed_rect.y - 50.0).abs() < 0.5,
5560 "gap should place row 1 at y=50"
5561 );
5562 let metrics = state
5563 .scroll
5564 .metrics
5565 .get(&*root.computed_id)
5566 .expect("virtual list writes scroll metrics");
5567 assert!(
5568 (metrics.content_h - 490.0).abs() < 0.5,
5569 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5570 metrics.content_h
5571 );
5572 }
5573
5574 #[test]
5575 fn virtual_list_dyn_caches_measured_heights() {
5576 let mut root = crate::tree::virtual_list_dyn(
5580 50,
5581 50.0,
5582 |i| format!("row-{i}"),
5583 |i| {
5584 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5585 .key(format!("row-{i}"))
5586 .height(Size::Fixed(30.0))
5587 },
5588 );
5589 let mut state = UiState::new();
5590 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5591
5592 let measured = state
5593 .scroll
5594 .measured_row_heights
5595 .get(&*root.computed_id)
5596 .expect("dynamic virtual list should populate the height cache");
5597 assert!(
5601 measured.len() >= 6,
5602 "expected ≥ 6 cached row heights, got {}",
5603 measured.len()
5604 );
5605 for by_width in measured.values() {
5606 let h = by_width
5607 .get(&300)
5608 .copied()
5609 .expect("measurement should be keyed at the 300px width bucket");
5610 assert!(
5611 (h - 30.0).abs() < 0.5,
5612 "expected cached height ≈ 30, got {h}"
5613 );
5614 }
5615 }
5616
5617 #[test]
5618 fn virtual_list_dyn_realized_rows_place_at_their_measured_heights() {
5619 let mut root = crate::tree::virtual_list_dyn(
5626 50,
5627 20.0,
5628 |i| format!("row-{i}"),
5629 |i| {
5630 crate::tree::column([crate::widgets::text::text(format!("row body {i}"))])
5631 .key(format!("row-{i}"))
5632 .height(Size::Hug)
5633 },
5634 );
5635 let mut state = UiState::new();
5636 let _ = take_intrinsic_cache_stats();
5637 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5638 assert!(!root.children.is_empty(), "test requires realized rows");
5639 let stored = state
5640 .scroll
5641 .measured_row_heights
5642 .get(&*root.computed_id)
5643 .expect("dynamic list stores per-row heights");
5644 let bucket = virtual_width_bucket(300.0);
5645 for row in &root.children {
5646 let key = row.key.as_deref().expect("rows are keyed");
5647 let h = stored
5648 .get(key)
5649 .and_then(|by_width| by_width.get(&bucket))
5650 .copied()
5651 .expect("realized row height stored at the current width bucket");
5652 assert!(
5653 (row.computed_rect.h - h).abs() < 0.01,
5654 "row {key} placed at h={} but measured h={h}",
5655 row.computed_rect.h,
5656 );
5657 }
5658 let stats = take_intrinsic_cache_stats();
5661 assert_eq!(stats.hits, 0);
5662 assert!(stats.misses > 0, "sizing visits should be reported");
5663 }
5664
5665 #[test]
5666 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
5667 let make_root = || {
5668 crate::tree::virtual_list_dyn(
5669 100,
5670 40.0,
5671 |i| format!("row-{i}"),
5672 |i| {
5673 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5674 .key(format!("row-{i}"))
5675 .height(Size::Fixed(40.0))
5676 },
5677 )
5678 };
5679 let mut root = make_root();
5680 let mut state = UiState::new();
5681 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5682
5683 state
5684 .scroll
5685 .offsets
5686 .insert(root.computed_id.clone().to_string(), 400.0);
5687 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5688
5689 let anchor = state
5690 .scroll
5691 .virtual_anchors
5692 .get(&*root.computed_id)
5693 .cloned()
5694 .expect("dynamic list should store a visible anchor");
5695 let before_y = root
5696 .children
5697 .iter()
5698 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5699 .map(|child| child.computed_rect.y)
5700 .expect("anchor row should be realized");
5701 let before_offset = state.scroll_offset(&root.computed_id);
5702
5703 state
5704 .scroll
5705 .measured_row_heights
5706 .entry(root.computed_id.clone().to_string())
5707 .or_default()
5708 .entry("row-0".to_string())
5709 .or_default()
5710 .insert(300, 120.0);
5711
5712 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5713 let after_y = root
5714 .children
5715 .iter()
5716 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5717 .map(|child| child.computed_rect.y)
5718 .expect("anchor row should remain realized");
5719 let after_offset = state.scroll_offset(&root.computed_id);
5720
5721 assert!(
5722 (after_y - before_y).abs() < 0.5,
5723 "anchor row should stay at y={before_y}, got {after_y}"
5724 );
5725 assert!(
5726 (after_offset - (before_offset + 80.0)).abs() < 0.5,
5727 "offset should absorb the 80px measurement delta above anchor"
5728 );
5729 }
5730
5731 #[test]
5732 fn virtual_list_dyn_height_cache_is_width_bucketed() {
5733 let mut root = crate::tree::virtual_list_dyn(
5734 20,
5735 50.0,
5736 |i| format!("row-{i}"),
5737 |i| {
5738 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5739 .key(format!("row-{i}"))
5740 .height(Size::Fixed(30.0))
5741 },
5742 );
5743 let mut state = UiState::new();
5744 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5745 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
5746
5747 let row_0 = state
5748 .scroll
5749 .measured_row_heights
5750 .get(&*root.computed_id)
5751 .and_then(|m| m.get("row-0"))
5752 .expect("row 0 should be measured");
5753 assert!(
5754 row_0.contains_key(&300) && row_0.contains_key(&240),
5755 "expected width buckets 300 and 240, got {:?}",
5756 row_0.keys().collect::<Vec<_>>()
5757 );
5758 }
5759
5760 #[test]
5761 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
5762 let make_root = || {
5767 crate::tree::virtual_list_dyn(
5768 20,
5769 50.0,
5770 |i| format!("row-{i}"),
5771 |i| {
5772 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5773 .key(format!("row-{i}"))
5774 .height(Size::Fixed(30.0))
5775 },
5776 )
5777 };
5778 let mut state = UiState::new();
5779 let mut root = make_root();
5780 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5781
5782 state
5783 .scroll
5784 .offsets
5785 .insert(root.computed_id.clone().to_string(), 9999.0);
5786 let mut root2 = make_root();
5787 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5788
5789 let measured = state
5790 .scroll
5791 .measured_row_heights
5792 .get(&*root2.computed_id)
5793 .expect("dynamic virtual list should populate the height cache");
5794 let measured_sum = measured
5795 .values()
5796 .filter_map(|by_width| by_width.get(&300))
5797 .sum::<f32>();
5798 let measured_count = measured
5799 .values()
5800 .filter(|by_width| by_width.contains_key(&300))
5801 .count();
5802 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
5803 let expected_max_offset = expected_total - 200.0;
5804
5805 let stored = state
5806 .scroll
5807 .offsets
5808 .get(&*root2.computed_id)
5809 .copied()
5810 .unwrap_or(0.0);
5811 assert!(
5812 (stored - expected_max_offset).abs() < 0.5,
5813 "expected offset clamped to {expected_max_offset}, got {stored}"
5814 );
5815 }
5816
5817 fn parity_list(first: usize, count: usize, append_only: bool, pin: bool) -> El {
5831 let mut el = crate::tree::virtual_list_dyn(
5832 count,
5833 20.0,
5834 move |i| format!("m{}", first + i),
5835 move |i| {
5836 let id = first + i;
5837 let h = 30.0 + (id % 7) as f32 * 10.0;
5838 crate::tree::column([crate::widgets::text::text(format!("m{id}"))])
5839 .key(format!("m{id}"))
5840 .height(Size::Fixed(h))
5841 },
5842 )
5843 .key("chat")
5844 .gap(4.0);
5845 if pin {
5846 el = el.pin_end();
5847 }
5848 if append_only {
5849 el = el.append_only();
5850 }
5851 el
5852 }
5853
5854 fn parity_rows(root: &El) -> Vec<(String, Rect)> {
5856 root.children
5857 .iter()
5858 .map(|c| (c.key.clone().unwrap_or_default(), c.computed_rect))
5859 .collect()
5860 }
5861
5862 struct Frame {
5863 first: usize,
5864 count: usize,
5865 seed_offset: Option<f32>,
5866 request: Option<ScrollRequest>,
5867 }
5868
5869 fn run_parity(frames: &[Frame], pin: bool) {
5870 let mut sg = UiState::new(); let mut si = UiState::new(); for (n, f) in frames.iter().enumerate() {
5874 let viewport = Rect::new(0.0, 0.0, 300.0, 200.0);
5875 let step = |state: &mut UiState, append_only: bool| {
5876 let mut root = parity_list(f.first, f.count, append_only, pin);
5877 assign_ids(&mut root);
5878 if let Some(o) = f.seed_offset {
5879 state
5880 .scroll
5881 .offsets
5882 .insert(root.computed_id.clone().to_string(), o);
5883 }
5884 if let Some(req) = &f.request {
5885 state.push_scroll_requests(vec![req.clone()]);
5886 }
5887 layout(&mut root, state, viewport);
5888 root
5889 };
5890
5891 let root_g = step(&mut sg, false);
5892 let root_i = step(&mut si, true);
5893
5894 let rows_g = parity_rows(&root_g);
5895 let rows_i = parity_rows(&root_i);
5896 assert_eq!(
5897 rows_g.len(),
5898 rows_i.len(),
5899 "frame {n}: realized row count differs (general {} vs incremental {})",
5900 rows_g.len(),
5901 rows_i.len()
5902 );
5903 for ((kg, rg), (ki, ri)) in rows_g.iter().zip(&rows_i) {
5904 assert_eq!(kg, ki, "frame {n}: realized key order differs");
5905 assert!(
5906 (rg.x - ri.x).abs() < 1e-2
5907 && (rg.y - ri.y).abs() < 1e-2
5908 && (rg.w - ri.w).abs() < 1e-2
5909 && (rg.h - ri.h).abs() < 1e-2,
5910 "frame {n}: rect for {kg} differs: general {rg:?} vs incremental {ri:?}"
5911 );
5912 }
5913
5914 let off_g = sg.scroll.offsets.get(&*root_g.computed_id).copied();
5915 let off_i = si.scroll.offsets.get(&*root_i.computed_id).copied();
5916 assert!(
5917 (off_g.unwrap_or(0.0) - off_i.unwrap_or(0.0)).abs() < 1e-2,
5918 "frame {n}: stored offset differs: general {off_g:?} vs incremental {off_i:?}"
5919 );
5920 assert_eq!(
5921 sg.visible_range("chat"),
5922 si.visible_range("chat"),
5923 "frame {n}: visible range differs"
5924 );
5925 }
5926 }
5927
5928 #[test]
5929 fn append_only_matches_general_top_append_scroll_trim() {
5930 run_parity(
5931 &[
5932 Frame {
5934 first: 0,
5935 count: 30,
5936 seed_offset: Some(0.0),
5937 request: None,
5938 },
5939 Frame {
5941 first: 0,
5942 count: 42,
5943 seed_offset: None,
5944 request: None,
5945 },
5946 Frame {
5948 first: 0,
5949 count: 42,
5950 seed_offset: Some(400.0),
5951 request: None,
5952 },
5953 Frame {
5955 first: 8,
5956 count: 50,
5957 seed_offset: None,
5958 request: None,
5959 },
5960 Frame {
5962 first: 8,
5963 count: 62,
5964 seed_offset: None,
5965 request: None,
5966 },
5967 Frame {
5969 first: 20,
5970 count: 62,
5971 seed_offset: None,
5972 request: None,
5973 },
5974 ],
5975 false,
5976 );
5977 }
5978
5979 #[test]
5980 fn append_only_matches_general_to_row_key_request() {
5981 run_parity(
5982 &[
5983 Frame {
5984 first: 0,
5985 count: 40,
5986 seed_offset: Some(0.0),
5987 request: None,
5988 },
5989 Frame {
5991 first: 0,
5992 count: 40,
5993 seed_offset: None,
5994 request: Some(ScrollRequest::ToRowKey {
5995 list_key: "chat".into(),
5996 row_key: "m30".into(),
5997 align: ScrollAlignment::Start,
5998 }),
5999 },
6000 Frame {
6002 first: 12,
6003 count: 55,
6004 seed_offset: None,
6005 request: None,
6006 },
6007 ],
6008 false,
6009 );
6010 }
6011
6012 #[test]
6018 fn append_only_matches_general_across_width_change() {
6019 let mut sg = UiState::new();
6020 let mut si = UiState::new();
6021 let script = [
6023 (0usize, 30usize, 300.0f32),
6024 (0, 40, 300.0),
6025 (0, 40, 240.0), (6, 52, 240.0), (6, 52, 360.0), ];
6029 for (n, &(first, count, w)) in script.iter().enumerate() {
6030 let viewport = Rect::new(0.0, 0.0, w, 200.0);
6031 let step = |state: &mut UiState, append_only: bool| {
6032 let mut root = parity_list(first, count, append_only, false);
6033 assign_ids(&mut root);
6034 layout(&mut root, state, viewport);
6035 root
6036 };
6037 let rg = step(&mut sg, false);
6038 let ri = step(&mut si, true);
6039 let rows_g = parity_rows(&rg);
6040 let rows_i = parity_rows(&ri);
6041 assert_eq!(rows_g.len(), rows_i.len(), "frame {n}: row count differs");
6042 for ((kg, a), (ki, b)) in rows_g.iter().zip(&rows_i) {
6043 assert_eq!(kg, ki, "frame {n}: key order differs");
6044 assert!(
6045 (a.x - b.x).abs() < 1e-2
6046 && (a.y - b.y).abs() < 1e-2
6047 && (a.w - b.w).abs() < 1e-2
6048 && (a.h - b.h).abs() < 1e-2,
6049 "frame {n}: rect for {kg} differs: {a:?} vs {b:?}"
6050 );
6051 }
6052 assert_eq!(
6053 sg.visible_range("chat"),
6054 si.visible_range("chat"),
6055 "frame {n}: visible range differs"
6056 );
6057 }
6058 }
6059
6060 #[test]
6061 fn append_only_matches_general_pin_end_stick_to_bottom() {
6062 run_parity(
6063 &[
6064 Frame {
6065 first: 0,
6066 count: 20,
6067 seed_offset: None,
6068 request: None,
6069 },
6070 Frame {
6072 first: 0,
6073 count: 35,
6074 seed_offset: None,
6075 request: None,
6076 },
6077 Frame {
6079 first: 10,
6080 count: 50,
6081 seed_offset: None,
6082 request: None,
6083 },
6084 Frame {
6085 first: 25,
6086 count: 70,
6087 seed_offset: None,
6088 request: None,
6089 },
6090 ],
6091 true,
6092 );
6093 }
6094
6095 #[test]
6096 fn virtual_list_dyn_empty_count_realizes_no_children() {
6097 let mut root = crate::tree::virtual_list_dyn(
6098 0,
6099 50.0,
6100 |i| format!("row-{i}"),
6101 |i| crate::widgets::text::text(format!("r{i}")),
6102 );
6103 let mut state = UiState::new();
6104 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
6105 assert_eq!(root.children.len(), 0);
6106 }
6107
6108 #[test]
6109 #[should_panic(expected = "estimated_row_height > 0.0")]
6110 fn virtual_list_dyn_zero_estimate_panics() {
6111 let _ = crate::tree::virtual_list_dyn(
6112 10,
6113 0.0,
6114 |i| format!("row-{i}"),
6115 |i| crate::widgets::text::text(format!("r{i}")),
6116 );
6117 }
6118
6119 #[test]
6120 fn text_runs_constructor_shape_smoke() {
6121 let el = crate::tree::text_runs([
6122 crate::widgets::text::text("Hello, "),
6123 crate::widgets::text::text("world").bold(),
6124 crate::tree::hard_break(),
6125 crate::widgets::text::text("of text").italic(),
6126 ]);
6127 assert_eq!(el.kind, Kind::Inlines);
6128 assert_eq!(el.children.len(), 4);
6129 assert!(matches!(
6130 el.children[1].font_weight,
6131 FontWeight::Bold | FontWeight::Semibold
6132 ));
6133 assert_eq!(el.children[2].kind, Kind::HardBreak);
6134 assert!(el.children[3].text_italic);
6135 }
6136
6137 #[test]
6138 fn wrapped_text_hugs_multiline_height_from_available_width() {
6139 let mut root = column([crate::paragraph(
6140 "A longer sentence should wrap into multiple measured lines.",
6141 )])
6142 .width(Size::Fill(1.0))
6143 .height(Size::Hug);
6144
6145 let mut state = UiState::new();
6146 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
6147
6148 let child_rect = root.children[0].computed_rect;
6149 assert_eq!(child_rect.w, 180.0);
6150 assert!(
6151 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
6152 "expected multiline paragraph height, got {}",
6153 child_rect.h
6154 );
6155 }
6156
6157 #[test]
6158 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
6159 const PANEL_W: f32 = 240.0;
6170 const PADDING: f32 = 18.0;
6171 const GAP: f32 = 12.0;
6172
6173 let panel = column([
6174 crate::paragraph(
6175 "A long enough warning paragraph that it has to wrap onto a second line \
6176 inside this narrow panel.",
6177 ),
6178 crate::widgets::button::button("OK").key("ok"),
6179 ])
6180 .width(Size::Fixed(PANEL_W))
6181 .height(Size::Hug)
6182 .padding(Sides::all(PADDING))
6183 .gap(GAP)
6184 .align(Align::Stretch);
6185
6186 let mut root = crate::stack([panel])
6187 .width(Size::Fill(1.0))
6188 .height(Size::Fill(1.0));
6189 let mut state = UiState::new();
6190 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6191
6192 let panel_rect = root.children[0].computed_rect;
6193 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
6194
6195 let para_rect = root.children[0].children[0].computed_rect;
6196 let button_rect = root.children[0].children[1].computed_rect;
6197
6198 assert!(
6201 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
6202 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
6203 got h={}",
6204 para_rect.h
6205 );
6206
6207 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
6213 assert!(
6214 (bottom_padding - PADDING).abs() < 0.5,
6215 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
6216 );
6217 }
6218
6219 #[test]
6220 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
6221 const COL_W: f32 = 600.0;
6233 const GUTTER_W: f32 = 3.0;
6234
6235 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
6236 sed do eiusmod tempor incididunt ut labore et dolore magna \
6237 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
6238 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
6239
6240 let make_row = || {
6241 let gutter = El::new(Kind::Custom("gutter"))
6242 .width(Size::Fixed(GUTTER_W))
6243 .height(Size::Fill(1.0));
6244 let body = crate::paragraph(long).width(Size::Fill(1.0));
6245 crate::row([gutter, body]).width(Size::Fill(1.0))
6246 };
6247
6248 let mut root = column([make_row(), make_row()])
6249 .width(Size::Fixed(COL_W))
6250 .height(Size::Hug)
6251 .align(Align::Stretch);
6252 let mut state = UiState::new();
6253 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
6254
6255 let row0_rect = root.children[0].computed_rect;
6256 let row1_rect = root.children[1].computed_rect;
6257 let para0_rect = root.children[0].children[1].computed_rect;
6258
6259 let line_height = crate::tokens::TEXT_SM.line_height;
6264 assert!(
6265 para0_rect.h > line_height * 1.5,
6266 "paragraph should wrap to multiple lines at ~597px wide; \
6267 got h={} (line_height={})",
6268 para0_rect.h,
6269 line_height,
6270 );
6271 assert!(
6272 row0_rect.h > line_height * 1.5,
6273 "row 0 should accommodate the wrapped paragraph height; \
6274 got h={} (line_height={})",
6275 row0_rect.h,
6276 line_height,
6277 );
6278
6279 assert!(
6281 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
6282 "row 1 starts at y={} but row 0 occupies y={}..{}",
6283 row1_rect.y,
6284 row0_rect.y,
6285 row0_rect.y + row0_rect.h,
6286 );
6287 }
6288
6289 #[test]
6294 fn min_width_floors_resolved_cross_axis_size() {
6295 let mut root = column([crate::widgets::text::text("hi")
6296 .width(Size::Fixed(40.0))
6297 .height(Size::Fixed(20.0))
6298 .min_width(120.0)])
6299 .align(Align::Start)
6300 .width(Size::Fixed(500.0))
6301 .height(Size::Fixed(200.0));
6302 let mut state = UiState::new();
6303 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6304 let child_rect = root.children[0].computed_rect;
6305 assert!(
6306 (child_rect.w - 120.0).abs() < 0.5,
6307 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
6308 child_rect.w,
6309 );
6310 }
6311
6312 #[test]
6315 fn max_width_caps_fill_child() {
6316 let mut root = crate::row([crate::widgets::text::text("body")
6317 .width(Size::Fill(1.0))
6318 .height(Size::Fixed(20.0))
6319 .max_width(160.0)])
6320 .width(Size::Fixed(800.0))
6321 .height(Size::Fixed(40.0));
6322 let mut state = UiState::new();
6323 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
6324 let child_rect = root.children[0].computed_rect;
6325 assert!(
6326 (child_rect.w - 160.0).abs() < 0.5,
6327 "expected Fill child capped at 160, got w={}",
6328 child_rect.w,
6329 );
6330 }
6331
6332 #[test]
6336 fn ch_unit_reserves_constant_digit_width() {
6337 use crate::widgets::text::text;
6338 let mut root = column([
6339 text("8")
6340 .tabular_numerals()
6341 .width(Size::Ch(4.0))
6342 .height(Size::Fixed(20.0)),
6343 text("123456")
6344 .tabular_numerals()
6345 .width(Size::Ch(4.0))
6346 .height(Size::Fixed(20.0)),
6347 text("8")
6348 .tabular_numerals()
6349 .width(Size::Ch(2.0))
6350 .height(Size::Fixed(20.0)),
6351 ]);
6352 let mut state = UiState::new();
6353 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6354 let four_a = root.children[0].computed_rect.w;
6355 let four_b = root.children[1].computed_rect.w;
6356 let two = root.children[2].computed_rect.w;
6357 assert!(four_a > 0.0);
6358 assert!(
6360 (four_a - four_b).abs() < 0.01,
6361 "Ch(4) width must not depend on the text: {four_a} vs {four_b}"
6362 );
6363 assert!(
6365 (four_a - 2.0 * two).abs() < 0.5,
6366 "Ch(4) should be twice Ch(2): {four_a} vs 2×{two}"
6367 );
6368 }
6369
6370 #[test]
6373 fn min_width_wins_over_max_width_when_conflicting() {
6374 let mut root = column([crate::widgets::text::text("x")
6375 .width(Size::Fixed(50.0))
6376 .height(Size::Fixed(20.0))
6377 .max_width(80.0)
6378 .min_width(120.0)]);
6379 let mut state = UiState::new();
6380 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6381 let child_rect = root.children[0].computed_rect;
6382 assert!(
6383 (child_rect.w - 120.0).abs() < 0.5,
6384 "expected min_width (120) to win over max_width (80), got w={}",
6385 child_rect.w,
6386 );
6387 }
6388
6389 #[test]
6393 fn min_height_floors_hug_column_inside_fixed_parent() {
6394 let inner = column([crate::widgets::text::text("a")
6395 .width(Size::Fixed(40.0))
6396 .height(Size::Fixed(20.0))])
6397 .width(Size::Fixed(80.0))
6398 .height(Size::Hug)
6399 .min_height(200.0);
6400 let mut root = column([inner])
6401 .align(Align::Start)
6402 .width(Size::Fixed(800.0))
6403 .height(Size::Fixed(600.0));
6404 let mut state = UiState::new();
6405 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6406 let inner_rect = root.children[0].computed_rect;
6407 assert!(
6408 (inner_rect.h - 200.0).abs() < 0.5,
6409 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
6410 inner_rect.h,
6411 );
6412 }
6413
6414 #[test]
6423 fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
6424 let mut root = crate::row([
6428 column([crate::widgets::text::paragraph(
6429 "A long enough description that must wrap to two lines at 148px",
6430 )])
6431 .width(Size::Fill(1.0)),
6432 crate::widgets::text::text("ok")
6433 .width(Size::Fixed(40.0))
6434 .height(Size::Fixed(20.0)),
6435 ])
6436 .gap(12.0)
6437 .align(Align::Center)
6438 .width(Size::Fixed(200.0));
6439 let mut state = UiState::new();
6440 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
6441 let col_rect = root.children[0].computed_rect;
6443 let para_rect = root.children[0].children[0].computed_rect;
6444 assert!(
6445 (col_rect.h - para_rect.h).abs() < 0.5,
6446 "column height ({}) should track its wrapped child's height ({})",
6447 col_rect.h,
6448 para_rect.h,
6449 );
6450 }
6451
6452 #[test]
6456 fn aspect_on_column_main_axis_derives_from_cross() {
6457 let mut root = column([El::new(Kind::Group)
6458 .width(Size::Fill(1.0))
6459 .height(Size::Aspect(0.5))])
6460 .width(Size::Fixed(200.0))
6461 .height(Size::Fixed(400.0));
6462 let mut state = UiState::new();
6463 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
6464 let r = root.children[0].computed_rect;
6465 assert!(
6466 (r.w - 200.0).abs() < 0.5,
6467 "expected w≈200 (Fill), got {}",
6468 r.w,
6469 );
6470 assert!(
6471 (r.h - 100.0).abs() < 0.5,
6472 "expected h≈100 (Aspect 0.5 of 200), got {}",
6473 r.h,
6474 );
6475 }
6476
6477 #[test]
6481 fn aspect_height_pushes_siblings_in_column() {
6482 let mut root = column([
6483 El::new(Kind::Group)
6484 .width(Size::Fill(1.0))
6485 .height(Size::Aspect(0.25)),
6486 crate::widgets::text::text("caption")
6487 .width(Size::Fixed(40.0))
6488 .height(Size::Fixed(20.0)),
6489 ])
6490 .width(Size::Fixed(400.0))
6491 .height(Size::Fixed(500.0));
6492 let mut state = UiState::new();
6493 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
6494 let img = root.children[0].computed_rect;
6495 let cap = root.children[1].computed_rect;
6496 assert!(
6497 (img.h - 100.0).abs() < 0.5,
6498 "expected aspect-derived height ≈100, got {}",
6499 img.h,
6500 );
6501 assert!(
6502 (cap.y - 100.0).abs() < 0.5,
6503 "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
6504 cap.y,
6505 );
6506 }
6507
6508 #[test]
6512 fn aspect_on_row_cross_axis_derives_from_main() {
6513 let mut root = crate::row([El::new(Kind::Group)
6514 .height(Size::Fill(1.0))
6515 .width(Size::Aspect(2.0))])
6516 .width(Size::Fixed(800.0))
6517 .height(Size::Fixed(200.0));
6518 let mut state = UiState::new();
6519 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
6520 let r = root.children[0].computed_rect;
6521 assert!(
6522 (r.h - 200.0).abs() < 0.5,
6523 "expected h≈200 (Fill), got {}",
6524 r.h,
6525 );
6526 assert!(
6527 (r.w - 400.0).abs() < 0.5,
6528 "expected w≈400 (Aspect 2.0 of 200), got {}",
6529 r.w,
6530 );
6531 }
6532
6533 #[test]
6536 fn aspect_on_both_axes_falls_back_to_intrinsic() {
6537 let mut root = column([crate::widgets::text::text("hi")
6538 .width(Size::Aspect(1.0))
6539 .height(Size::Aspect(1.0))])
6540 .width(Size::Fixed(200.0))
6541 .height(Size::Fixed(200.0));
6542 let mut state = UiState::new();
6543 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
6544 let r = root.children[0].computed_rect;
6545 assert!(
6546 r.w > 0.0 && r.h > 0.0,
6547 "expected finite size for both-Aspect fallback, got {}x{}",
6548 r.w,
6549 r.h,
6550 );
6551 }
6552
6553 #[test]
6557 fn aspect_respects_min_and_max_on_derived_axis() {
6558 let mut root = column([column([El::new(Kind::Group)
6562 .width(Size::Fill(1.0))
6563 .height(Size::Aspect(1.0))
6564 .max_height(120.0)])
6565 .width(Size::Hug)
6566 .height(Size::Hug)])
6567 .width(Size::Fixed(400.0))
6568 .height(Size::Fixed(600.0));
6569 let mut state = UiState::new();
6570 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6571 let panel = root.children[0].computed_rect;
6572 let img = root.children[0].children[0].computed_rect;
6573 assert!(
6574 (img.h - 120.0).abs() < 0.5,
6575 "max_height should clamp aspect-derived height to 120, got {}",
6576 img.h,
6577 );
6578 assert!(
6579 (panel.h - 120.0).abs() < 0.5,
6580 "hugging panel should match clamped child (120), got {}",
6581 panel.h,
6582 );
6583
6584 let mut root = column([column([El::new(Kind::Group)
6587 .width(Size::Fill(1.0))
6588 .height(Size::Aspect(0.1))
6589 .min_height(200.0)])
6590 .width(Size::Hug)
6591 .height(Size::Hug)])
6592 .width(Size::Fixed(400.0))
6593 .height(Size::Fixed(600.0));
6594 let mut state = UiState::new();
6595 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6596 let panel = root.children[0].computed_rect;
6597 let img = root.children[0].children[0].computed_rect;
6598 assert!(
6599 (img.h - 200.0).abs() < 0.5,
6600 "min_height should bump aspect-derived height to 200, got {}",
6601 img.h,
6602 );
6603 assert!(
6604 (panel.h - 200.0).abs() < 0.5,
6605 "hugging panel should match bumped child (200), got {}",
6606 panel.h,
6607 );
6608 }
6609
6610 #[test]
6613 fn aspect_basis_is_clamped_before_deriving() {
6614 let mut root = column([El::new(Kind::Group)
6620 .width(Size::Fill(1.0))
6621 .height(Size::Aspect(0.5))
6622 .max_width(100.0)])
6623 .width(Size::Fixed(400.0))
6624 .height(Size::Fixed(400.0));
6625 let mut state = UiState::new();
6626 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6627 let img = root.children[0].computed_rect;
6628 assert!(
6629 (img.w - 100.0).abs() < 0.5,
6630 "max_width should cap Fill width at 100, got {}",
6631 img.w,
6632 );
6633 assert!(
6634 (img.h - 50.0).abs() < 0.5,
6635 "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
6636 img.h,
6637 );
6638 }
6639
6640 #[test]
6646 fn hug_column_around_fill_aspect_child_does_not_overflow() {
6647 let mut root = column([column([El::new(Kind::Group)
6654 .width(Size::Fill(1.0))
6655 .height(Size::Aspect(0.5))])
6656 .width(Size::Hug)
6657 .height(Size::Hug)])
6658 .width(Size::Fixed(400.0))
6659 .height(Size::Fixed(400.0));
6660 let mut state = UiState::new();
6661 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6662 let panel = root.children[0].computed_rect;
6663 let img = root.children[0].children[0].computed_rect;
6664 assert!(
6665 (panel.h - 200.0).abs() < 0.5,
6666 "hugging panel should hug to aspect-derived height 200, got {}",
6667 panel.h,
6668 );
6669 assert!(
6670 (img.h - 200.0).abs() < 0.5,
6671 "image should layout to height 200, got {}",
6672 img.h,
6673 );
6674 assert!(
6675 img.bottom() <= panel.bottom() + 0.5,
6676 "image (bottom={}) must fit within hugging panel (bottom={})",
6677 img.bottom(),
6678 panel.bottom(),
6679 );
6680 }
6681
6682 #[test]
6686 fn hugging_parent_sees_aspect_corrected_intrinsic() {
6687 let mut root = column([column([El::new(Kind::Group)
6691 .width(Size::Fixed(80.0))
6692 .height(Size::Aspect(0.5))])
6693 .width(Size::Hug)
6694 .height(Size::Hug)])
6695 .width(Size::Fixed(400.0))
6696 .height(Size::Fixed(400.0))
6697 .align(Align::Start);
6698 let mut state = UiState::new();
6699 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6700 let hugger = root.children[0].computed_rect;
6701 assert!(
6702 (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
6703 "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
6704 hugger.w,
6705 hugger.h,
6706 );
6707 }
6708
6709 #[test]
6711 fn max_height_caps_overlay_child_below_intrinsic() {
6712 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
6715 .width(Size::Fixed(40.0))
6716 .height(Size::Fixed(300.0))])
6717 .width(Size::Hug)
6718 .height(Size::Hug)
6719 .max_height(100.0)])
6720 .width(Size::Fixed(600.0))
6721 .height(Size::Fixed(600.0));
6722 let mut state = UiState::new();
6723 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
6724 let child_rect = root.children[0].computed_rect;
6725 assert!(
6726 (child_rect.h - 100.0).abs() < 0.5,
6727 "expected child height capped at 100, got h={}",
6728 child_rect.h,
6729 );
6730 }
6731
6732 #[test]
6736 fn user_resizable_publishes_trailing_edge_band() {
6737 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6738 let mut root = crate::tree::row([
6739 column(Vec::<El>::new())
6740 .key("nav")
6741 .user_resizable()
6742 .width(Size::Fixed(200.0))
6743 .min_width(120.0)
6744 .max_width(420.0),
6745 column(Vec::<El>::new()).width(Size::Fill(1.0)),
6746 ])
6747 .height(Size::Fixed(400.0));
6748 let mut state = UiState::new();
6749 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6750
6751 assert_eq!(state.resize.bands.len(), 1);
6752 let band = &state.resize.bands[0];
6753 assert_eq!(band.id, "nav");
6754 assert_eq!(band.key.as_deref(), Some("nav"));
6755 assert_eq!(band.sign, 1.0, "trailing edge: drag-right grows");
6756 assert!((band.current - 200.0).abs() < 0.5);
6757 assert_eq!((band.min, band.max), (120.0, 420.0));
6758 assert!((band.band.x - (200.0 - T / 2.0)).abs() < 0.5);
6760 assert!((band.band.w - T).abs() < 0.5);
6761 assert!((band.band.h - 400.0).abs() < 0.5);
6762 }
6763
6764 #[test]
6769 fn user_resizable_last_child_gets_leading_edge_band() {
6770 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6771 let mut root = crate::tree::row([
6772 column(Vec::<El>::new()).width(Size::Fill(1.0)),
6773 column(Vec::<El>::new())
6774 .key("inspector")
6775 .user_resizable()
6776 .width(Size::Fixed(240.0)),
6777 ])
6778 .height(Size::Fixed(400.0));
6779 let mut state = UiState::new();
6780 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6781
6782 assert_eq!(state.resize.bands.len(), 1);
6783 let band = &state.resize.bands[0];
6784 assert_eq!(band.sign, -1.0, "leading edge: drag-left grows");
6785 assert!((band.band.x - (560.0 - T / 2.0)).abs() < 0.5);
6787 assert_eq!(band.min, 0.0);
6788 assert!(
6789 (band.max - 800.0).abs() < 0.5,
6790 "no max_width → capped at the parent's inner extent, got {}",
6791 band.max,
6792 );
6793 }
6794
6795 #[test]
6799 fn user_resizable_override_applies_and_clamps() {
6800 let build = || {
6801 crate::tree::row([
6802 column(Vec::<El>::new())
6803 .key("nav")
6804 .user_resizable()
6805 .width(Size::Fixed(200.0))
6806 .min_width(120.0)
6807 .max_width(420.0),
6808 column(Vec::<El>::new()).key("main").width(Size::Fill(1.0)),
6809 ])
6810 .height(Size::Fixed(400.0))
6811 };
6812 let mut state = UiState::new();
6813 state.set_user_size("nav", 300.0);
6814 let mut root = build();
6815 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6816 let nav = state.rect_of_key("nav").unwrap();
6817 assert!((nav.w - 300.0).abs() < 0.5, "override wins, got {}", nav.w);
6818 let main = state.rect_of_key("main").unwrap();
6819 assert!(
6820 (main.w - 500.0).abs() < 0.5,
6821 "the Fill sibling absorbs the change, got {}",
6822 main.w,
6823 );
6824
6825 state.set_user_size("nav", 9999.0);
6827 let mut root = build();
6828 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6829 assert!((state.rect_of_key("nav").unwrap().w - 420.0).abs() < 0.5);
6830
6831 state.clear_user_size("nav");
6833 let mut root = build();
6834 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6835 assert!((state.rect_of_key("nav").unwrap().w - 200.0).abs() < 0.5);
6836 }
6837
6838 #[test]
6842 fn user_resizable_in_column_resizes_height() {
6843 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6844 let mut root = column([
6845 crate::tree::row(Vec::<El>::new())
6846 .key("topbar")
6847 .user_resizable()
6848 .height(Size::Fixed(100.0))
6849 .min_height(40.0),
6850 crate::tree::row(Vec::<El>::new()).height(Size::Fill(1.0)),
6851 ])
6852 .width(Size::Fixed(800.0));
6853 let mut state = UiState::new();
6854 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6855
6856 assert_eq!(state.resize.bands.len(), 1);
6857 let band = &state.resize.bands[0];
6858 assert!((band.band.y - (100.0 - T / 2.0)).abs() < 0.5);
6859 assert!((band.band.w - 800.0).abs() < 0.5);
6860 assert_eq!(band.min, 40.0);
6861 assert!((band.current - 100.0).abs() < 0.5);
6862
6863 state.set_user_size("topbar", 250.0);
6864 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6865 assert!((state.rect_of_key("topbar").unwrap().h - 250.0).abs() < 0.5);
6866 }
6867}