1#![warn(missing_docs)]
36
37use std::cell::RefCell;
38use std::sync::Arc;
39
40use rustc_hash::{FxHashMap, FxHashSet};
41
42use crate::scroll::{ScrollAlignment, ScrollRequest};
43use crate::state::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)]
101pub struct LayoutIntrinsicCacheStats {
102 pub hits: u64,
104 pub misses: u64,
106}
107
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113pub struct LayoutPruneStats {
114 pub subtrees: u64,
116 pub nodes: u64,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, Hash)]
121struct IntrinsicCacheKey {
122 computed_id: String,
123 available_width_bits: Option<u32>,
124}
125
126#[derive(Default)]
127struct IntrinsicCache {
128 measurements: FxHashMap<IntrinsicCacheKey, (f32, f32)>,
129 stats: LayoutIntrinsicCacheStats,
130 prune: LayoutPruneStats,
131}
132
133thread_local! {
134 static INTRINSIC_CACHE: RefCell<Option<IntrinsicCache>> = const { RefCell::new(None) };
135 static LAST_INTRINSIC_CACHE_STATS: RefCell<LayoutIntrinsicCacheStats> =
136 const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
137 static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
138 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
139}
140
141struct IntrinsicCacheGuard {
142 previous: Option<IntrinsicCache>,
143}
144
145impl Drop for IntrinsicCacheGuard {
146 fn drop(&mut self) {
147 INTRINSIC_CACHE.with(|cell| {
148 cell.replace(self.previous.take());
149 });
150 }
151}
152
153fn with_intrinsic_cache(f: impl FnOnce()) {
154 let previous = INTRINSIC_CACHE.with(|cell| cell.replace(Some(IntrinsicCache::default())));
155 let mut guard = IntrinsicCacheGuard { previous };
156 f();
157 let finished = INTRINSIC_CACHE.with(|cell| cell.replace(guard.previous.take()));
158 if let Some(cache) = finished {
159 LAST_INTRINSIC_CACHE_STATS.with(|stats| {
160 *stats.borrow_mut() = cache.stats;
161 });
162 LAST_PRUNE_STATS.with(|stats| {
163 *stats.borrow_mut() = cache.prune;
164 });
165 }
166 std::mem::forget(guard);
167}
168
169pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
173 LAST_INTRINSIC_CACHE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
174}
175
176pub fn take_prune_stats() -> LayoutPruneStats {
180 LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
181}
182
183#[derive(Clone, Debug)]
206pub enum VirtualMode {
207 Fixed {
209 row_height: f32,
211 },
212 Dynamic {
216 estimated_row_height: f32,
219 append_only: bool,
232 },
233}
234
235#[derive(Clone, Copy, Debug, PartialEq)]
239pub enum VirtualAnchorPolicy {
240 ViewportFraction {
243 y_fraction: f32,
246 },
247 FirstVisible,
250 LastVisible,
253}
254
255impl Default for VirtualAnchorPolicy {
256 fn default() -> Self {
257 Self::ViewportFraction { y_fraction: 0.25 }
258 }
259}
260
261#[derive(Clone)]
268#[non_exhaustive]
269pub struct VirtualItems {
270 pub count: usize,
272 pub mode: VirtualMode,
274 pub anchor_policy: VirtualAnchorPolicy,
277 pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
281 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
284}
285
286impl VirtualItems {
287 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
291 where
292 F: Fn(usize) -> El + Send + Sync + 'static,
293 {
294 assert!(
295 row_height > 0.0,
296 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
297 );
298 VirtualItems {
299 count,
300 mode: VirtualMode::Fixed { row_height },
301 anchor_policy: VirtualAnchorPolicy::default(),
302 row_key: Arc::new(|i| i.to_string()),
303 build_row: Arc::new(build_row),
304 }
305 }
306
307 pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
313 where
314 K: Fn(usize) -> String + Send + Sync + 'static,
315 F: Fn(usize) -> El + Send + Sync + 'static,
316 {
317 assert!(
318 estimated_row_height > 0.0,
319 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
320 );
321 VirtualItems {
322 count,
323 mode: VirtualMode::Dynamic {
324 estimated_row_height,
325 append_only: false,
326 },
327 anchor_policy: VirtualAnchorPolicy::default(),
328 row_key: Arc::new(row_key),
329 build_row: Arc::new(build_row),
330 }
331 }
332
333 pub fn append_only(mut self) -> Self {
337 if let VirtualMode::Dynamic {
338 ref mut append_only,
339 ..
340 } = self.mode
341 {
342 *append_only = true;
343 }
344 self
345 }
346
347 pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
350 self.anchor_policy = policy;
351 self
352 }
353}
354
355impl std::fmt::Debug for VirtualItems {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 f.debug_struct("VirtualItems")
358 .field("count", &self.count)
359 .field("mode", &self.mode)
360 .field("anchor_policy", &self.anchor_policy)
361 .field("row_key", &"<fn>")
362 .field("build_row", &"<fn>")
363 .finish()
364 }
365}
366
367#[non_exhaustive]
372pub struct LayoutCtx<'a> {
373 pub container: Rect,
377 pub children: &'a [El],
380 pub measure: &'a dyn Fn(&El) -> (f32, f32),
384 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
391 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
397}
398
399pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
408 {
409 crate::profile_span!("layout::assign_ids");
410 assign_id(root, "root");
411 }
412 layout_post_assign(root, ui_state, viewport);
413}
414
415pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
421 with_intrinsic_cache(|| {
422 {
423 crate::profile_span!("layout::root_setup");
424 ui_state
425 .layout
426 .computed_rects
427 .insert(root.computed_id.clone(), viewport);
428 rebuild_key_index(root, ui_state);
429 ui_state.scroll.metrics.clear();
433 ui_state.scroll.thumb_rects.clear();
434 ui_state.scroll.thumb_tracks.clear();
435 ui_state.scroll.visible_ranges.clear();
436 ui_state.resize.bands.clear();
437 apply_resize_overrides(root, ui_state);
442 normalize_ch_sizes(root);
446 }
447 crate::profile_span!("layout::children");
448 layout_children(root, viewport, ui_state);
449 publish_resize_bands(root, ui_state);
451 });
452}
453
454fn resize_clamp(child: &El, axis: Axis) -> (f32, f32) {
459 let (min, max) = match axis {
460 Axis::Column => (child.min_height, child.max_height),
461 _ => (child.min_width, child.max_width),
462 };
463 let min = min.unwrap_or(0.0).max(0.0);
464 (min, max.unwrap_or(f32::INFINITY).max(min))
465}
466
467fn apply_resize_overrides(node: &mut El, ui_state: &UiState) {
473 let axis = node.axis;
474 for child in &mut node.children {
475 if child.user_resizable && !matches!(axis, Axis::Overlay) {
476 let id = child.key.as_deref().unwrap_or(&child.computed_id);
477 if let Some(&px) = ui_state.resize.overrides.get(id) {
478 let (min, max) = resize_clamp(child, axis);
479 let px = px.clamp(min, max);
480 match axis {
481 Axis::Column => child.height = Size::Fixed(px),
482 _ => child.width = Size::Fixed(px),
483 }
484 }
485 }
486 apply_resize_overrides(child, ui_state);
487 }
488}
489
490fn ch_unit(node: &El) -> f32 {
495 text_metrics::layout_text_with_family(
496 "0",
497 node.font_size,
498 node.font_family,
499 node.font_weight,
500 node.font_mono,
501 node.text_tabular_numerals,
502 TextWrap::NoWrap,
503 None,
504 )
505 .width
506 .max(0.0)
507}
508
509fn normalize_ch_sizes(node: &mut El) {
513 if let Size::Ch(n) = node.width {
514 node.width = Size::Fixed((n * ch_unit(node)).max(0.0));
515 }
516 if let Size::Ch(n) = node.height {
517 node.height = Size::Fixed((n * ch_unit(node)).max(0.0));
518 }
519 for child in &mut node.children {
520 normalize_ch_sizes(child);
521 }
522}
523
524fn publish_resize_bands(node: &El, ui_state: &mut UiState) {
532 use crate::state::resize::{RESIZE_BAND_THICKNESS as T, ResizeBand};
533 let axis = node.axis;
534 if !matches!(axis, Axis::Overlay) && node.children.iter().any(|c| c.user_resizable) {
535 let parent_rect = ui_state
536 .layout
537 .computed_rects
538 .get(&node.computed_id)
539 .copied();
540 if let Some(parent_rect) = parent_rect {
541 let inner_main = match axis {
542 Axis::Column => parent_rect.h - node.padding.top - node.padding.bottom,
543 _ => parent_rect.w - node.padding.left - node.padding.right,
544 };
545 let count = node.children.len();
546 for (idx, child) in node.children.iter().enumerate() {
547 if !child.user_resizable {
548 continue;
549 }
550 let Some(rect) = ui_state
551 .layout
552 .computed_rects
553 .get(&child.computed_id)
554 .copied()
555 else {
556 continue;
557 };
558 let trailing = idx + 1 < count || count == 1;
562 let band = match (axis, trailing) {
563 (Axis::Column, true) => Rect::new(rect.x, rect.y + rect.h - T / 2.0, rect.w, T),
564 (Axis::Column, false) => Rect::new(rect.x, rect.y - T / 2.0, rect.w, T),
565 (_, true) => Rect::new(rect.x + rect.w - T / 2.0, rect.y, T, rect.h),
566 (_, false) => Rect::new(rect.x - T / 2.0, rect.y, T, rect.h),
567 };
568 let (min, max) = resize_clamp(child, axis);
569 let max = max.min(inner_main.max(min));
573 ui_state.resize.bands.push(ResizeBand {
574 id: child
575 .key
576 .clone()
577 .unwrap_or_else(|| child.computed_id.clone()),
578 key: child.key.clone(),
579 container_id: node.computed_id.clone(),
580 band,
581 axis,
582 sign: if trailing { 1.0 } else { -1.0 },
583 current: match axis {
584 Axis::Column => rect.h,
585 _ => rect.w,
586 },
587 min,
588 max,
589 });
590 }
591 }
592 }
593 for child in &node.children {
594 publish_resize_bands(child, ui_state);
595 }
596}
597
598pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
605 let role = role_token(&child.kind);
606 let suffix = match (&child.key, role) {
607 (Some(k), r) => format!("{r}[{k}]"),
608 (None, r) => format!("{r}.{child_index}"),
609 };
610 assign_id(child, &format!("{parent_id}.{suffix}"));
611}
612
613fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
618 ui_state.layout.key_index.clear();
619 let mut id_counts: rustc_hash::FxHashMap<&str, u32> = Default::default();
627 fn visit<'a>(
628 node: &'a El,
629 index: &mut rustc_hash::FxHashMap<String, String>,
630 id_counts: &mut rustc_hash::FxHashMap<&'a str, u32>,
631 ) {
632 if let Some(key) = &node.key {
633 index
634 .entry(key.clone())
635 .or_insert_with(|| node.computed_id.clone());
636 }
637 *id_counts.entry(node.computed_id.as_str()).or_insert(0) += 1;
638 for c in &node.children {
639 visit(c, index, id_counts);
640 }
641 }
642 visit(root, &mut ui_state.layout.key_index, &mut id_counts);
643 warn_duplicate_ids(&id_counts, &mut ui_state.layout.warned_duplicate_ids);
644}
645
646fn warn_duplicate_ids(
653 id_counts: &rustc_hash::FxHashMap<&str, u32>,
654 warned: &mut rustc_hash::FxHashSet<String>,
655) {
656 for (&id, &n) in id_counts {
657 if n > 1 && warned.insert(id.to_string()) {
658 log::warn!(
659 "DuplicateId: {n} nodes share id {id} — duplicate sibling key; \
660 their rects and intrinsic-cache entries collide silently (issue #64)"
661 );
662 }
663 }
664}
665
666pub fn assign_ids(root: &mut El) {
670 assign_id(root, "root");
671}
672
673fn assign_id(node: &mut El, path: &str) {
674 node.computed_id = path.to_string();
675 for (i, c) in node.children.iter_mut().enumerate() {
676 let role = role_token(&c.kind);
677 let suffix = match (&c.key, role) {
678 (Some(k), r) => format!("{r}[{k}]"),
679 (None, r) => format!("{r}.{i}"),
680 };
681 let child_path = format!("{path}.{suffix}");
682 assign_id(c, &child_path);
683 }
684}
685
686fn role_token(k: &Kind) -> &'static str {
687 match k {
688 Kind::Group => "group",
689 Kind::Card => "card",
690 Kind::Button => "button",
691 Kind::Badge => "badge",
692 Kind::Text => "text",
693 Kind::Heading => "heading",
694 Kind::Spacer => "spacer",
695 Kind::Divider => "divider",
696 Kind::Overlay => "overlay",
697 Kind::Scrim => "scrim",
698 Kind::Modal => "modal",
699 Kind::Scroll => "scroll",
700 Kind::VirtualList => "virtual_list",
701 Kind::Inlines => "inlines",
702 Kind::HardBreak => "hard_break",
703 Kind::Math => "math",
704 Kind::Image => "image",
705 Kind::Surface => "surface",
706 Kind::Vector => "vector",
707 Kind::Scene3D => "scene3d",
708 Kind::Plot => "plot",
709 Kind::Viewport => "viewport",
710 Kind::Custom(name) => name,
711 }
712}
713
714fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
715 if matches!(node.kind, Kind::Inlines) {
716 for c in &mut node.children {
724 ui_state.layout.computed_rects.insert(
725 c.computed_id.clone(),
726 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
727 );
728 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
732 }
733 return;
734 }
735 if let Some(items) = node.virtual_items.clone() {
736 layout_virtual(node, node_rect, items, ui_state);
737 return;
738 }
739 if let Some(layout_fn) = node.layout_override.clone() {
740 layout_custom(node, node_rect, layout_fn, ui_state);
741 if node.scrollable {
742 apply_scroll_offset(node, node_rect, ui_state);
743 }
744 if node.viewport.is_some() {
745 apply_viewport_transform(node, node_rect, ui_state);
746 }
747 return;
748 }
749 match node.axis {
750 Axis::Overlay => {
751 let inner = node_rect.inset(node.padding);
752 for c in &mut node.children {
753 let c_rect = overlay_rect(c, inner, node.align, node.justify);
754 ui_state
755 .layout
756 .computed_rects
757 .insert(c.computed_id.clone(), c_rect);
758 layout_children(c, c_rect, ui_state);
759 }
760 }
761 Axis::Column => layout_axis(node, node_rect, true, ui_state),
762 Axis::Row => layout_axis(node, node_rect, false, ui_state),
763 }
764 if node.scrollable {
765 apply_scroll_offset(node, node_rect, ui_state);
766 }
767 if node.viewport.is_some() {
768 apply_viewport_transform(node, node_rect, ui_state);
769 }
770}
771
772fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
773 let inner = node_rect.inset(node.padding);
774 let measure = |c: &El| intrinsic(c);
775 let key_index = &ui_state.layout.key_index;
780 let computed_rects = &ui_state.layout.computed_rects;
781 let rect_of_key = |key: &str| -> Option<Rect> {
782 let id = key_index.get(key)?;
783 computed_rects.get(id).copied()
784 };
785 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
786 let rects = (layout_fn.0)(LayoutCtx {
787 container: inner,
788 children: &node.children,
789 measure: &measure,
790 rect_of_key: &rect_of_key,
791 rect_of_id: &rect_of_id,
792 });
793 assert_eq!(
794 rects.len(),
795 node.children.len(),
796 "LayoutFn for {:?} returned {} rects for {} children",
797 node.computed_id,
798 rects.len(),
799 node.children.len(),
800 );
801 for (c, c_rect) in node.children.iter_mut().zip(rects) {
802 ui_state
803 .layout
804 .computed_rects
805 .insert(c.computed_id.clone(), c_rect);
806 layout_children(c, c_rect, ui_state);
807 }
808}
809
810fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
816 let inner = node_rect.inset(node.padding);
817 match items.mode {
818 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
819 node,
820 inner,
821 items.count,
822 row_height,
823 items.build_row,
824 ui_state,
825 ),
826 VirtualMode::Dynamic {
827 estimated_row_height,
828 append_only,
829 } => {
830 let fns = DynamicVirtualFns {
831 anchor_policy: items.anchor_policy,
832 row_key: items.row_key,
833 build_row: items.build_row,
834 };
835 if append_only {
836 layout_virtual_dynamic_incremental(
837 node,
838 inner,
839 items.count,
840 estimated_row_height,
841 fns,
842 ui_state,
843 );
844 } else {
845 layout_virtual_dynamic(
846 node,
847 inner,
848 items.count,
849 estimated_row_height,
850 fns,
851 ui_state,
852 );
853 }
854 }
855 }
856}
857
858fn resolve_scroll_requests<F, K>(
868 node: &El,
869 inner: Rect,
870 count: usize,
871 row_extent: F,
872 row_for_key: K,
873 ui_state: &mut UiState,
874) -> bool
875where
876 F: Fn(usize) -> (f32, f32),
877 K: Fn(&str) -> Option<usize>,
878{
879 if ui_state.scroll.pending_requests.is_empty() {
880 return false;
881 }
882 let Some(key) = node.key.as_deref() else {
883 return false;
884 };
885 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
886 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
887 pending.into_iter().partition(|req| match req {
888 ScrollRequest::ToRow { list_key, .. } => list_key == key,
889 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
890 ScrollRequest::EnsureVisible { .. } => false,
893 });
894 ui_state.scroll.pending_requests = remaining;
895
896 let mut wrote = false;
897 for req in matched {
898 let (row, align) = match req {
899 ScrollRequest::ToRow { row, align, .. } => (row, align),
900 ScrollRequest::ToRowKey { row_key, align, .. } => {
901 let Some(row) = row_for_key(&row_key) else {
902 continue;
903 };
904 (row, align)
905 }
906 ScrollRequest::EnsureVisible { .. } => continue,
907 };
908 if row >= count {
909 continue;
910 }
911 let (row_top, row_h) = row_extent(row);
912 let row_bottom = row_top + row_h;
913 let viewport_h = inner.h;
914 let current = ui_state
915 .scroll
916 .offsets
917 .get(&node.computed_id)
918 .copied()
919 .unwrap_or(0.0);
920 let new_offset = match align {
921 ScrollAlignment::Start => row_top,
922 ScrollAlignment::End => row_bottom - viewport_h,
923 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
924 ScrollAlignment::Visible => {
925 if row_top < current {
926 row_top
927 } else if row_bottom > current + viewport_h {
928 row_bottom - viewport_h
929 } else {
930 continue;
931 }
932 }
933 };
934 ui_state
935 .scroll
936 .offsets
937 .insert(node.computed_id.clone(), new_offset);
938 wrote = true;
939 }
940 wrote
941}
942
943fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
946 let max_offset = (total_h - inner.h).max(0.0);
947 let stored = ui_state
948 .scroll
949 .offsets
950 .get(&node.computed_id)
951 .copied()
952 .unwrap_or(0.0);
953 let stored = resolve_pin(node, stored, max_offset, ui_state);
954 let offset = stored.clamp(0.0, max_offset);
955 ui_state
956 .scroll
957 .offsets
958 .insert(node.computed_id.clone(), offset);
959 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
960 offset
961}
962
963fn write_virtual_scroll_metrics(
964 node: &El,
965 inner: Rect,
966 total_h: f32,
967 max_offset: f32,
968 offset: f32,
969 ui_state: &mut UiState,
970) {
971 ui_state.scroll.metrics.insert(
972 node.computed_id.clone(),
973 crate::state::ScrollMetrics {
974 viewport_h: inner.h,
975 content_h: total_h,
976 max_offset,
977 },
978 );
979 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
980}
981
982fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
986 let role = role_token(&child.kind);
987 let suffix = match (&child.key, role) {
988 (Some(k), r) => format!("{r}[{k}]"),
989 (None, r) => format!("{r}.{global_i}"),
990 };
991 assign_id(child, &format!("{parent_id}.{suffix}"));
992}
993
994fn layout_virtual_fixed(
995 node: &mut El,
996 inner: Rect,
997 count: usize,
998 row_height: f32,
999 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1000 ui_state: &mut UiState,
1001) {
1002 let gap = node.gap.max(0.0);
1003 let pitch = row_height + gap;
1004 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
1005 resolve_scroll_requests(
1006 node,
1007 inner,
1008 count,
1009 |i| (i as f32 * pitch, row_height),
1010 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
1011 ui_state,
1012 );
1013 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
1014
1015 if count == 0 {
1016 node.children.clear();
1017 return;
1018 }
1019
1020 let start = (offset / pitch).floor() as usize;
1024 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
1025
1026 let mut realized: Vec<El> = Vec::new();
1027 let mut realized_range: Option<(usize, usize)> = None;
1028 for global_i in start..end {
1029 let row_top = global_i as f32 * pitch;
1030 if row_top >= offset + inner.h || row_top + row_height <= offset {
1031 continue;
1032 }
1033 let mut child = (build_row)(global_i);
1034 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
1035
1036 let row_y = inner.y + row_top - offset;
1037 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
1038 ui_state
1039 .layout
1040 .computed_rects
1041 .insert(child.computed_id.clone(), c_rect);
1042 layout_children(&mut child, c_rect, ui_state);
1043 realized.push(child);
1044 realized_range = Some(match realized_range {
1045 None => (global_i, global_i + 1),
1046 Some((s, _)) => (s, global_i + 1),
1047 });
1048 }
1049 if let Some((s, e)) = realized_range {
1050 ui_state
1051 .scroll
1052 .visible_ranges
1053 .insert(node.computed_id.clone(), (s, e));
1054 }
1055 node.children = realized;
1056}
1057
1058fn layout_virtual_dynamic_incremental(
1070 node: &mut El,
1071 inner: Rect,
1072 count: usize,
1073 estimated_row_height: f32,
1074 fns: DynamicVirtualFns,
1075 ui_state: &mut UiState,
1076) {
1077 let gap = node.gap.max(0.0);
1078 let width_bucket = virtual_width_bucket(inner.w);
1079
1080 if count == 0 {
1081 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
1082 ui_state.scroll.dyn_height_index.remove(&node.computed_id);
1083 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1084 debug_assert_eq!(offset, 0.0);
1085 node.children.clear();
1086 return;
1087 }
1088
1089 let existing = ui_state.scroll.dyn_height_index.remove(&node.computed_id);
1096 let head_key = (fns.row_key)(0);
1097 let mut trimmed_keys: Vec<String> = Vec::new();
1098 let reconciled = existing.and_then(|mut ix| {
1099 let ok = ix.reconcile(
1100 width_bucket,
1101 estimated_row_height,
1102 count,
1103 &head_key,
1104 |i| (fns.row_key)(i),
1105 |_i, key| {
1106 cached_row_height(
1107 ui_state,
1108 &node.computed_id,
1109 key,
1110 width_bucket,
1111 estimated_row_height,
1112 )
1113 },
1114 &mut trimmed_keys,
1115 );
1116 ok.then_some(ix)
1117 });
1118 let mut index = match reconciled {
1119 Some(ix) => ix,
1120 None => DynHeightIndex::build(width_bucket, estimated_row_height, count, |i| {
1121 let key = (fns.row_key)(i);
1122 let h = cached_row_height(
1123 ui_state,
1124 &node.computed_id,
1125 &key,
1126 width_bucket,
1127 estimated_row_height,
1128 );
1129 (key, h)
1130 }),
1131 };
1132 if !trimmed_keys.is_empty() {
1137 if let Some(measured) = ui_state
1138 .scroll
1139 .measured_row_heights
1140 .get_mut(&node.computed_id)
1141 {
1142 for key in &trimmed_keys {
1143 measured.remove(key);
1144 }
1145 if measured.is_empty() {
1146 ui_state
1147 .scroll
1148 .measured_row_heights
1149 .remove(&node.computed_id);
1150 }
1151 }
1152 }
1153
1154 let has_request = node.key.as_deref().is_some_and(|k| {
1157 ui_state.scroll.pending_requests.iter().any(|r| match r {
1158 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1159 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1160 ScrollRequest::EnsureVisible { .. } => false,
1161 })
1162 });
1163 let mut request_wrote = false;
1164 if has_request {
1165 request_wrote = resolve_scroll_requests(
1166 node,
1167 inner,
1168 count,
1169 |target| (index.row_top(target, gap), index.height(target)),
1170 |row_key| index.index_for_key(row_key),
1171 ui_state,
1172 );
1173 }
1174
1175 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1176 let max_offset = (total_h - inner.h).max(0.0);
1177 let stored = ui_state
1178 .scroll
1179 .offsets
1180 .get(&node.computed_id)
1181 .copied()
1182 .unwrap_or(0.0);
1183 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1184 let provisional_offset = if pin_active {
1185 match node.pin_policy {
1186 crate::tree::PinPolicy::End => max_offset,
1187 crate::tree::PinPolicy::Start => 0.0,
1188 crate::tree::PinPolicy::None => unreachable!(),
1189 }
1190 } else if request_wrote {
1191 stored
1192 } else {
1193 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1194 }
1195 .clamp(0.0, max_offset);
1196
1197 let (measure_start, _, measure_end) = index.visible_range(gap, provisional_offset, inner.h);
1198 measure_dynamic_range(
1199 node,
1200 DynamicRangeCtx {
1201 inner,
1202 keys: KeySource::Func(&*fns.row_key),
1203 width_bucket,
1204 build_row: &fns.build_row,
1205 },
1206 measure_start,
1207 measure_end,
1208 ui_state,
1209 );
1210 refresh_index_range(
1211 &mut index,
1212 &node.computed_id,
1213 width_bucket,
1214 measure_start,
1215 measure_end,
1216 &*fns.row_key,
1217 estimated_row_height,
1218 ui_state,
1219 );
1220
1221 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1222 let max_offset = (total_h - inner.h).max(0.0);
1223 let stored = ui_state
1224 .scroll
1225 .offsets
1226 .get(&node.computed_id)
1227 .copied()
1228 .unwrap_or(0.0);
1229 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1230 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1231 && ui_state
1232 .scroll
1233 .pin_active
1234 .get(&node.computed_id)
1235 .copied()
1236 .unwrap_or(false);
1237 let mut offset = if pin_active {
1238 pin_resolved
1239 } else if request_wrote {
1240 stored
1241 } else {
1242 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1243 }
1244 .clamp(0.0, max_offset);
1245
1246 ui_state
1247 .scroll
1248 .offsets
1249 .insert(node.computed_id.clone(), offset);
1250
1251 let (start, start_y, end) = index.visible_range(gap, offset, inner.h);
1252 let mut realized_rows = layout_dynamic_range(
1253 node,
1254 DynamicRangeCtx {
1255 inner,
1256 keys: KeySource::Func(&*fns.row_key),
1257 width_bucket,
1258 build_row: &fns.build_row,
1259 },
1260 offset,
1261 start,
1262 start_y,
1263 end,
1264 ui_state,
1265 );
1266 refresh_index_range(
1267 &mut index,
1268 &node.computed_id,
1269 width_bucket,
1270 start,
1271 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 corrected_offset = if pin_active {
1280 match node.pin_policy {
1281 crate::tree::PinPolicy::End => max_offset,
1282 crate::tree::PinPolicy::Start => 0.0,
1283 crate::tree::PinPolicy::None => unreachable!(),
1284 }
1285 } else if request_wrote {
1286 offset
1287 } else {
1288 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(offset)
1289 }
1290 .clamp(0.0, max_offset);
1291 if (corrected_offset - offset).abs() > 0.01 {
1292 let dy = offset - corrected_offset;
1293 for child in &node.children {
1294 shift_subtree_y(child, dy, ui_state);
1295 }
1296 for row in &mut realized_rows {
1297 row.rect.y += dy;
1298 }
1299 offset = corrected_offset;
1300 ui_state
1301 .scroll
1302 .offsets
1303 .insert(node.computed_id.clone(), offset);
1304 }
1305 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1306 ui_state
1307 .scroll
1308 .pin_prev_max
1309 .insert(node.computed_id.clone(), max_offset);
1310 }
1311 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1312
1313 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1314 ui_state
1315 .scroll
1316 .virtual_anchors
1317 .insert(node.computed_id.clone(), anchor);
1318 } else {
1319 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
1320 }
1321
1322 ui_state
1323 .scroll
1324 .dyn_height_index
1325 .insert(node.computed_id.clone(), index);
1326}
1327
1328fn cached_row_height(
1332 ui_state: &UiState,
1333 id: &str,
1334 key: &str,
1335 width_bucket: u32,
1336 estimated_row_height: f32,
1337) -> f32 {
1338 ui_state
1339 .scroll
1340 .measured_row_heights
1341 .get(id)
1342 .and_then(|m| m.get(key))
1343 .and_then(|by_width| by_width.get(&width_bucket))
1344 .copied()
1345 .unwrap_or(estimated_row_height)
1346}
1347
1348#[allow(clippy::too_many_arguments)]
1352fn refresh_index_range(
1353 index: &mut DynHeightIndex,
1354 id: &str,
1355 width_bucket: u32,
1356 start: usize,
1357 end: usize,
1358 row_key: &(dyn Fn(usize) -> String + Send + Sync),
1359 estimated_row_height: f32,
1360 ui_state: &UiState,
1361) {
1362 for idx in start..end {
1363 let key = row_key(idx);
1364 let h = cached_row_height(ui_state, id, &key, width_bucket, estimated_row_height);
1365 index.set_height(idx, h);
1366 }
1367}
1368
1369fn dynamic_anchor_offset_indexed(
1373 node: &El,
1374 index: &DynHeightIndex,
1375 gap: f32,
1376 stored: f32,
1377 ui_state: &UiState,
1378) -> Option<f32> {
1379 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1380 let idx = index.index_for_key(&anchor.row_key)?;
1381 let row_h = index.height(idx).max(0.0);
1382 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1383 let scroll_delta = stored - anchor.resolved_offset;
1384 let viewport_y = anchor.viewport_y - scroll_delta;
1385 Some(index.row_top(idx, gap) + row_point - viewport_y)
1386}
1387
1388fn layout_virtual_dynamic(
1389 node: &mut El,
1390 inner: Rect,
1391 count: usize,
1392 estimated_row_height: f32,
1393 fns: DynamicVirtualFns,
1394 ui_state: &mut UiState,
1395) {
1396 let gap = node.gap.max(0.0);
1397 let width_bucket = virtual_width_bucket(inner.w);
1398 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
1399 prune_dynamic_measurements(node, &row_keys, ui_state);
1400
1401 if count == 0 {
1402 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
1403 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1404 debug_assert_eq!(offset, 0.0);
1405 node.children.clear();
1406 return;
1407 }
1408
1409 let mut row_heights = dynamic_row_heights(
1410 node,
1411 &row_keys,
1412 width_bucket,
1413 estimated_row_height,
1414 ui_state,
1415 );
1416
1417 let has_request = node.key.as_deref().is_some_and(|k| {
1423 ui_state.scroll.pending_requests.iter().any(|r| match r {
1424 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1425 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1426 ScrollRequest::EnsureVisible { .. } => false,
1427 })
1428 });
1429 let mut request_wrote = false;
1430 if has_request {
1431 request_wrote = resolve_scroll_requests(
1432 node,
1433 inner,
1434 count,
1435 |target| {
1436 (
1437 dynamic_row_top(&row_heights, gap, target),
1438 row_heights[target],
1439 )
1440 },
1441 |row_key| row_keys.iter().position(|key| key == row_key),
1442 ui_state,
1443 );
1444 }
1445
1446 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1447 let max_offset = (total_h - inner.h).max(0.0);
1448 let stored = ui_state
1449 .scroll
1450 .offsets
1451 .get(&node.computed_id)
1452 .copied()
1453 .unwrap_or(0.0);
1454 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1455 let provisional_offset = if pin_active {
1456 match node.pin_policy {
1457 crate::tree::PinPolicy::End => max_offset,
1458 crate::tree::PinPolicy::Start => 0.0,
1459 crate::tree::PinPolicy::None => unreachable!(),
1460 }
1461 } else if request_wrote {
1462 stored
1463 } else {
1464 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1465 .unwrap_or(stored)
1466 }
1467 .clamp(0.0, max_offset);
1468
1469 let (measure_start, _, measure_end) =
1470 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
1471 measure_dynamic_range(
1472 node,
1473 DynamicRangeCtx {
1474 inner,
1475 keys: KeySource::Slice(&row_keys),
1476 width_bucket,
1477 build_row: &fns.build_row,
1478 },
1479 measure_start,
1480 measure_end,
1481 ui_state,
1482 );
1483
1484 row_heights = dynamic_row_heights(
1485 node,
1486 &row_keys,
1487 width_bucket,
1488 estimated_row_height,
1489 ui_state,
1490 );
1491 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1492 let max_offset = (total_h - inner.h).max(0.0);
1493 let stored = ui_state
1494 .scroll
1495 .offsets
1496 .get(&node.computed_id)
1497 .copied()
1498 .unwrap_or(0.0);
1499 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1500 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1501 && ui_state
1502 .scroll
1503 .pin_active
1504 .get(&node.computed_id)
1505 .copied()
1506 .unwrap_or(false);
1507 let mut offset = if pin_active {
1508 pin_resolved
1509 } else if request_wrote {
1510 stored
1511 } else {
1512 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1513 .unwrap_or(stored)
1514 }
1515 .clamp(0.0, max_offset);
1516
1517 ui_state
1518 .scroll
1519 .offsets
1520 .insert(node.computed_id.clone(), offset);
1521
1522 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
1523 let mut realized_rows = layout_dynamic_range(
1524 node,
1525 DynamicRangeCtx {
1526 inner,
1527 keys: KeySource::Slice(&row_keys),
1528 width_bucket,
1529 build_row: &fns.build_row,
1530 },
1531 offset,
1532 start,
1533 start_y,
1534 end,
1535 ui_state,
1536 );
1537
1538 row_heights = dynamic_row_heights(
1539 node,
1540 &row_keys,
1541 width_bucket,
1542 estimated_row_height,
1543 ui_state,
1544 );
1545 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1546 let max_offset = (total_h - inner.h).max(0.0);
1547 let corrected_offset = if pin_active {
1548 match node.pin_policy {
1549 crate::tree::PinPolicy::End => max_offset,
1550 crate::tree::PinPolicy::Start => 0.0,
1551 crate::tree::PinPolicy::None => unreachable!(),
1552 }
1553 } else if request_wrote {
1554 offset
1555 } else {
1556 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1557 .unwrap_or(offset)
1558 }
1559 .clamp(0.0, max_offset);
1560 if (corrected_offset - offset).abs() > 0.01 {
1561 let dy = offset - corrected_offset;
1562 for child in &node.children {
1563 shift_subtree_y(child, dy, ui_state);
1564 }
1565 for row in &mut realized_rows {
1566 row.rect.y += dy;
1567 }
1568 offset = corrected_offset;
1569 ui_state
1570 .scroll
1571 .offsets
1572 .insert(node.computed_id.clone(), offset);
1573 }
1574 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1575 ui_state
1576 .scroll
1577 .pin_prev_max
1578 .insert(node.computed_id.clone(), max_offset);
1579 }
1580 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1581
1582 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1583 ui_state
1584 .scroll
1585 .virtual_anchors
1586 .insert(node.computed_id.clone(), anchor);
1587 } else {
1588 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
1589 }
1590}
1591
1592struct DynamicVirtualFns {
1593 anchor_policy: VirtualAnchorPolicy,
1594 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
1595 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1596}
1597
1598#[derive(Clone, Copy)]
1599struct DynamicRangeCtx<'a> {
1600 inner: Rect,
1601 keys: KeySource<'a>,
1602 width_bucket: u32,
1603 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
1604}
1605
1606#[derive(Clone, Copy)]
1612enum KeySource<'a> {
1613 Slice(&'a [String]),
1614 Func(&'a (dyn Fn(usize) -> String + Send + Sync)),
1615}
1616
1617impl KeySource<'_> {
1618 fn key(&self, idx: usize) -> String {
1619 match self {
1620 KeySource::Slice(keys) => keys[idx].clone(),
1621 KeySource::Func(f) => f(idx),
1622 }
1623 }
1624}
1625
1626fn virtual_width_bucket(width: f32) -> u32 {
1627 width.max(0.0).round().min(u32::MAX as f32) as u32
1628}
1629
1630fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
1631 let Some(measurements) = ui_state
1632 .scroll
1633 .measured_row_heights
1634 .get_mut(&node.computed_id)
1635 else {
1636 return;
1637 };
1638 let live_keys = row_keys
1639 .iter()
1640 .map(String::as_str)
1641 .collect::<FxHashSet<_>>();
1642 measurements.retain(|key, widths| {
1643 let live = live_keys.contains(key.as_str());
1644 if live {
1645 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
1646 }
1647 live && !widths.is_empty()
1648 });
1649 if measurements.is_empty() {
1650 ui_state
1651 .scroll
1652 .measured_row_heights
1653 .remove(&node.computed_id);
1654 }
1655}
1656
1657fn dynamic_row_heights(
1658 node: &El,
1659 row_keys: &[String],
1660 width_bucket: u32,
1661 estimated_row_height: f32,
1662 ui_state: &UiState,
1663) -> Vec<f32> {
1664 let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
1665 row_keys
1666 .iter()
1667 .map(|key| {
1668 measurements
1669 .and_then(|m| m.get(key))
1670 .and_then(|by_width| by_width.get(&width_bucket))
1671 .copied()
1672 .unwrap_or(estimated_row_height)
1673 })
1674 .collect()
1675}
1676
1677fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1678 row_heights
1679 .iter()
1680 .take(target)
1681 .fold(0.0, |y, h| y + *h + gap)
1682}
1683
1684fn dynamic_visible_range(
1685 row_heights: &[f32],
1686 gap: f32,
1687 offset: f32,
1688 viewport_h: f32,
1689) -> (usize, f32, usize) {
1690 let count = row_heights.len();
1691 let mut start = 0;
1692 let mut y = 0.0_f32;
1693 while start < count {
1694 let h = row_heights[start];
1695 if y + h > offset {
1696 break;
1697 }
1698 y += h + gap;
1699 start += 1;
1700 }
1701
1702 let mut end = start;
1703 let mut cursor = y;
1704 let viewport_bottom = offset + viewport_h;
1705 while end < count && cursor < viewport_bottom {
1706 let h = row_heights[end];
1707 end += 1;
1708 cursor += h + gap;
1709 }
1710 (start, y, end)
1711}
1712
1713fn dynamic_anchor_offset(
1714 node: &El,
1715 row_keys: &[String],
1716 row_heights: &[f32],
1717 gap: f32,
1718 stored: f32,
1719 ui_state: &UiState,
1720) -> Option<f32> {
1721 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1722 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1723 anchor.row_index
1724 } else {
1725 row_keys.iter().position(|key| key == &anchor.row_key)?
1726 };
1727 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1728 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1729 let scroll_delta = stored - anchor.resolved_offset;
1730 let viewport_y = anchor.viewport_y - scroll_delta;
1731 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1732}
1733
1734fn measure_dynamic_range(
1735 node: &El,
1736 ctx: DynamicRangeCtx<'_>,
1737 start: usize,
1738 end: usize,
1739 ui_state: &mut UiState,
1740) {
1741 if start >= end {
1742 return;
1743 }
1744 let mut new_measurements = Vec::new();
1745 for idx in start..end {
1746 let key = ctx.keys.key(idx);
1747 let mut child = (ctx.build_row)(idx);
1748 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1754 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1755 new_measurements.push((key, actual_h));
1756 }
1757 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1758}
1759
1760fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1761 match child.height {
1762 Size::Fixed(v) => v.max(0.0),
1763 Size::Ch(n) => (n * ch_unit(child)).max(0.0),
1764 Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1765 Size::Aspect(r) => (width * r).max(0.0),
1766 Size::Fill(_) => panic!(
1767 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1768 or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1769 virtualization",
1770 node.computed_id,
1771 ),
1772 }
1773}
1774
1775const MAX_WIDTH_BUCKETS_PER_ROW: usize = 8;
1782
1783fn store_dynamic_measurements(
1784 node: &El,
1785 width_bucket: u32,
1786 measurements: Vec<(String, f32)>,
1787 ui_state: &mut UiState,
1788) {
1789 if measurements.is_empty() {
1790 return;
1791 }
1792 let entry = ui_state
1793 .scroll
1794 .measured_row_heights
1795 .entry(node.computed_id.clone())
1796 .or_default();
1797 for (row_key, h) in measurements {
1798 let buckets = entry.entry(row_key).or_default();
1799 buckets.insert(width_bucket, h);
1800 if buckets.len() > MAX_WIDTH_BUCKETS_PER_ROW {
1801 let mut widths: Vec<u32> = buckets.keys().copied().collect();
1804 widths.sort_unstable_by_key(|w| (i64::from(*w) - i64::from(width_bucket)).abs());
1805 for w in widths.drain(MAX_WIDTH_BUCKETS_PER_ROW..) {
1806 buckets.remove(&w);
1807 }
1808 }
1809 }
1810}
1811
1812#[derive(Clone, Debug)]
1813struct DynamicRealizedRow {
1814 index: usize,
1815 key: String,
1816 rect: Rect,
1817}
1818
1819fn layout_dynamic_range(
1820 node: &mut El,
1821 ctx: DynamicRangeCtx<'_>,
1822 offset: f32,
1823 start: usize,
1824 start_y: f32,
1825 end: usize,
1826 ui_state: &mut UiState,
1827) -> Vec<DynamicRealizedRow> {
1828 let gap = node.gap.max(0.0);
1829 let mut cursor_y = start_y;
1830 let mut realized = Vec::new();
1831 let mut realized_rows = Vec::new();
1832 let mut new_measurements = Vec::new();
1833
1834 for idx in start..end {
1835 let key = ctx.keys.key(idx);
1836 let mut child = (ctx.build_row)(idx);
1837 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1838 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1839 new_measurements.push((key.clone(), actual_h));
1840
1841 let row_y = ctx.inner.y + cursor_y - offset;
1842 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1843 ui_state
1844 .layout
1845 .computed_rects
1846 .insert(child.computed_id.clone(), c_rect);
1847 layout_children(&mut child, c_rect, ui_state);
1848
1849 realized_rows.push(DynamicRealizedRow {
1850 index: idx,
1851 key,
1852 rect: c_rect,
1853 });
1854 realized.push(child);
1855 cursor_y += actual_h + gap;
1856 }
1857
1858 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1859 if let (Some(first), Some(last)) = (realized_rows.first(), realized_rows.last()) {
1860 ui_state
1861 .scroll
1862 .visible_ranges
1863 .insert(node.computed_id.clone(), (first.index, last.index + 1));
1864 }
1865 node.children = realized;
1866 realized_rows
1867}
1868
1869fn choose_dynamic_anchor(
1870 policy: VirtualAnchorPolicy,
1871 inner: Rect,
1872 offset: f32,
1873 rows: &[DynamicRealizedRow],
1874) -> Option<VirtualAnchor> {
1875 let visible = rows
1876 .iter()
1877 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1878 .collect::<Vec<_>>();
1879 if visible.is_empty() {
1880 return None;
1881 }
1882
1883 let chosen = match policy {
1884 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1885 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1886 visible
1887 .iter()
1888 .min_by(|a, b| {
1889 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1890 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1891 ad.total_cmp(&bd)
1892 })
1893 .copied()
1894 .map(|row| {
1895 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1896 (row.clone(), anchor_y)
1897 })
1898 }
1899 VirtualAnchorPolicy::FirstVisible => {
1900 let row = visible
1901 .iter()
1902 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1903 .or_else(|| visible.first())
1904 .copied()?;
1905 let anchor_y = row.rect.y.max(inner.y);
1906 Some((row.clone(), anchor_y))
1907 }
1908 VirtualAnchorPolicy::LastVisible => {
1909 let row = visible
1910 .iter()
1911 .rev()
1912 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1913 .or_else(|| visible.last())
1914 .copied()?;
1915 let anchor_y = row.rect.bottom().min(inner.bottom());
1916 Some((row.clone(), anchor_y))
1917 }
1918 }?;
1919
1920 let (row, anchor_y) = chosen;
1921 let row_h = row.rect.h.max(0.0);
1922 let row_fraction = if row_h > 0.0 {
1923 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1924 } else {
1925 0.0
1926 };
1927 Some(VirtualAnchor {
1928 row_key: row.key.clone(),
1929 row_index: row.index,
1930 row_fraction,
1931 viewport_y: anchor_y - inner.y,
1932 resolved_offset: offset,
1933 })
1934}
1935
1936fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1937 if y < top {
1938 top - y
1939 } else if y > bottom {
1940 y - bottom
1941 } else {
1942 0.0
1943 }
1944}
1945
1946fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1947 if count == 0 {
1948 0.0
1949 } else {
1950 row_sum + gap * count.saturating_sub(1) as f32
1951 }
1952}
1953
1954fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1962 let inner = node_rect.inset(node.padding);
1963 if node.children.is_empty() {
1964 ui_state
1965 .scroll
1966 .offsets
1967 .insert(node.computed_id.clone(), 0.0);
1968 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1969 ui_state.scroll.metrics.insert(
1970 node.computed_id.clone(),
1971 crate::state::ScrollMetrics {
1972 viewport_h: inner.h,
1973 content_h: 0.0,
1974 max_offset: 0.0,
1975 },
1976 );
1977 return;
1978 }
1979 let content_bottom = node
1980 .children
1981 .iter()
1982 .map(|c| ui_state.rect(&c.computed_id).bottom())
1983 .fold(f32::NEG_INFINITY, f32::max);
1984 let content_h = (content_bottom - inner.y).max(0.0);
1985 let max_offset = (content_h - inner.h).max(0.0);
1986
1987 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1995
1996 let stored = ui_state
1997 .scroll
1998 .offsets
1999 .get(&node.computed_id)
2000 .copied()
2001 .unwrap_or(0.0);
2002 let stored = resolve_pin(node, stored, max_offset, ui_state);
2003 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
2004 && ui_state
2005 .scroll
2006 .pin_active
2007 .get(&node.computed_id)
2008 .copied()
2009 .unwrap_or(false);
2010 let stored = if pin_active || request_wrote {
2011 stored
2012 } else {
2013 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
2014 };
2015 let clamped = stored.clamp(0.0, max_offset);
2016 if clamped > 0.0 {
2017 for c in &node.children {
2018 shift_subtree_y(c, -clamped, ui_state);
2019 }
2020 }
2021 ui_state
2022 .scroll
2023 .offsets
2024 .insert(node.computed_id.clone(), clamped);
2025 ui_state.scroll.metrics.insert(
2026 node.computed_id.clone(),
2027 crate::state::ScrollMetrics {
2028 viewport_h: inner.h,
2029 content_h,
2030 max_offset,
2031 },
2032 );
2033
2034 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
2035
2036 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
2037 ui_state
2038 .scroll
2039 .scroll_anchors
2040 .insert(node.computed_id.clone(), anchor);
2041 } else {
2042 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
2043 }
2044}
2045
2046fn apply_viewport_transform(node: &El, node_rect: Rect, ui_state: &mut UiState) {
2057 use crate::viewport::ViewportView;
2058 let cfg = node
2059 .viewport
2060 .expect("apply_viewport_transform called on a non-viewport node");
2061 let inner = node_rect.inset(node.padding);
2062 let origin = (inner.x, inner.y);
2063 let content = viewport_content_bbox(node, ui_state);
2064
2065 let mut view = ui_state
2068 .viewport
2069 .views
2070 .get(&node.computed_id)
2071 .copied()
2072 .unwrap_or_default();
2073 if let Some(key) = node.key.as_deref() {
2074 let mut i = 0;
2075 while i < ui_state.viewport.pending_requests.len() {
2076 if ui_state.viewport.pending_requests[i].key() == key {
2077 let req = ui_state.viewport.pending_requests.remove(i);
2078 view = apply_viewport_request(req, cfg, inner, origin, content, view);
2079 } else {
2080 i += 1;
2081 }
2082 }
2083 }
2084
2085 view.zoom = view.zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2088 if let Some(c) = content {
2089 clamp_viewport_pan(&mut view, cfg.pan_bounds, inner, origin, c);
2090 }
2091
2092 if view != ViewportView::default() {
2094 transform_viewport_subtree(node, view, origin, ui_state);
2095 }
2096
2097 ui_state
2098 .viewport
2099 .views
2100 .insert(node.computed_id.clone(), view);
2101 ui_state.viewport.metrics.insert(
2102 node.computed_id.clone(),
2103 crate::state::ViewportMetrics {
2104 inner,
2105 content,
2106 cfg,
2107 },
2108 );
2109}
2110
2111fn viewport_content_bbox(node: &El, ui_state: &UiState) -> Option<Rect> {
2115 let mut acc: Option<Rect> = None;
2116 for c in &node.children {
2117 if let Some(r) = ui_state.layout.computed_rects.get(&c.computed_id) {
2118 acc = Some(acc.map_or(*r, |a| union_rect(a, *r)));
2119 }
2120 if let Some(bb) = viewport_content_bbox(c, ui_state) {
2121 acc = Some(acc.map_or(bb, |a| union_rect(a, bb)));
2122 }
2123 }
2124 acc
2125}
2126
2127fn union_rect(a: Rect, b: Rect) -> Rect {
2129 let x = a.x.min(b.x);
2130 let y = a.y.min(b.y);
2131 let r = a.right().max(b.right());
2132 let bot = a.bottom().max(b.bottom());
2133 Rect::new(x, y, r - x, bot - y)
2134}
2135
2136fn transform_viewport_subtree(
2139 node: &El,
2140 view: crate::viewport::ViewportView,
2141 origin: (f32, f32),
2142 ui_state: &mut UiState,
2143) {
2144 for c in &node.children {
2145 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&c.computed_id) {
2146 let (nx, ny) = view.project((rect.x, rect.y), origin);
2147 *rect = Rect::new(nx, ny, rect.w * view.zoom, rect.h * view.zoom);
2148 }
2149 transform_viewport_subtree(c, view, origin, ui_state);
2150 }
2151}
2152
2153fn apply_viewport_request(
2157 req: crate::viewport::ViewportRequest,
2158 cfg: crate::viewport::ViewportConfig,
2159 inner: Rect,
2160 origin: (f32, f32),
2161 content: Option<Rect>,
2162 current: crate::viewport::ViewportView,
2163) -> crate::viewport::ViewportView {
2164 use crate::viewport::{ViewportRequest, ViewportView};
2165 match req {
2166 ViewportRequest::ResetView { .. } => ViewportView::default(),
2167 ViewportRequest::CenterOn { point, .. } => {
2168 viewport_center_on(inner, origin, current.zoom, point)
2169 }
2170 ViewportRequest::FitContent { padding, .. } => match content {
2171 Some(c) if c.w > 0.0 || c.h > 0.0 => {
2172 let avail_w = (inner.w - 2.0 * padding).max(1.0);
2173 let avail_h = (inner.h - 2.0 * padding).max(1.0);
2174 let mut zoom = f32::INFINITY;
2175 if c.w > 0.0 {
2176 zoom = zoom.min(avail_w / c.w);
2177 }
2178 if c.h > 0.0 {
2179 zoom = zoom.min(avail_h / c.h);
2180 }
2181 if !zoom.is_finite() {
2182 return current;
2183 }
2184 let zoom = zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2185 viewport_center_on(inner, origin, zoom, (c.center_x(), c.center_y()))
2186 }
2187 _ => current,
2188 },
2189 }
2190}
2191
2192fn viewport_center_on(
2195 inner: Rect,
2196 origin: (f32, f32),
2197 zoom: f32,
2198 point: (f32, f32),
2199) -> crate::viewport::ViewportView {
2200 crate::viewport::ViewportView {
2201 pan: (
2202 inner.center_x() - origin.0 - zoom * (point.0 - origin.0),
2203 inner.center_y() - origin.1 - zoom * (point.1 - origin.1),
2204 ),
2205 zoom,
2206 }
2207}
2208
2209fn clamp_viewport_pan(
2213 view: &mut crate::viewport::ViewportView,
2214 bounds: crate::viewport::PanBounds,
2215 inner: Rect,
2216 origin: (f32, f32),
2217 content: Rect,
2218) {
2219 if matches!(bounds, crate::viewport::PanBounds::Free) {
2220 return;
2221 }
2222 let (lx, ty) = view.project((content.x, content.y), origin);
2223 let w = content.w * view.zoom;
2224 let h = content.h * view.zoom;
2225 view.pan.0 += clamp_axis_delta(bounds, lx, lx + w, inner.x, inner.right(), w, inner.w);
2226 view.pan.1 += clamp_axis_delta(bounds, ty, ty + h, inner.y, inner.bottom(), h, inner.h);
2227}
2228
2229fn clamp_axis_delta(
2233 bounds: crate::viewport::PanBounds,
2234 lo: f32,
2235 hi: f32,
2236 vlo: f32,
2237 vhi: f32,
2238 size: f32,
2239 vsize: f32,
2240) -> f32 {
2241 use crate::viewport::PanBounds;
2242 match bounds {
2243 PanBounds::Free => 0.0,
2245 PanBounds::Center => {
2248 let vc = 0.5 * (vlo + vhi);
2249 if lo > vc {
2250 vc - lo
2251 } else if hi < vc {
2252 vc - hi
2253 } else {
2254 0.0
2255 }
2256 }
2257 PanBounds::Contain => {
2258 if size <= vsize {
2259 if lo < vlo {
2261 vlo - lo
2262 } else if hi > vhi {
2263 vhi - hi
2264 } else {
2265 0.0
2266 }
2267 } else {
2268 if lo > vlo {
2270 vlo - lo
2271 } else if hi < vhi {
2272 vhi - hi
2273 } else {
2274 0.0
2275 }
2276 }
2277 }
2278 }
2279}
2280
2281fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
2282 let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
2283 let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
2284 if rect.h <= 0.0 {
2285 return None;
2286 }
2287 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
2288 let scroll_delta = stored - anchor.resolved_offset;
2289 let viewport_y = anchor.viewport_y - scroll_delta;
2290 Some(rect.y - inner.y + rect_point - viewport_y)
2291}
2292
2293fn choose_scroll_anchor(
2294 node: &El,
2295 inner: Rect,
2296 offset: f32,
2297 ui_state: &UiState,
2298) -> Option<ScrollAnchor> {
2299 if inner.h <= 0.0 {
2300 return None;
2301 }
2302 let target_y = inner.y + inner.h * 0.25;
2303 let mut best = None;
2304 for child in &node.children {
2305 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
2306 }
2307 let candidate = best?;
2308 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
2309 let rect_fraction = if candidate.rect.h > 0.0 {
2310 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
2311 } else {
2312 0.0
2313 };
2314 Some(ScrollAnchor {
2315 node_id: candidate.node_id,
2316 rect_fraction,
2317 viewport_y: anchor_y - inner.y,
2318 resolved_offset: offset,
2319 })
2320}
2321
2322#[derive(Clone, Debug)]
2323struct ScrollAnchorCandidate {
2324 node_id: String,
2325 rect: Rect,
2326 distance: f32,
2327 depth: usize,
2328}
2329
2330fn choose_scroll_anchor_in_subtree(
2331 node: &El,
2332 inner: Rect,
2333 target_y: f32,
2334 depth: usize,
2335 ui_state: &UiState,
2336 best: &mut Option<ScrollAnchorCandidate>,
2337) {
2338 let Some(rect) = ui_state
2339 .layout
2340 .computed_rects
2341 .get(&node.computed_id)
2342 .copied()
2343 else {
2344 return;
2345 };
2346 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
2347 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
2348 let candidate = ScrollAnchorCandidate {
2349 node_id: node.computed_id.clone(),
2350 rect,
2351 distance,
2352 depth,
2353 };
2354 let replace = best.as_ref().is_none_or(|current| {
2355 candidate.distance < current.distance
2356 || (candidate.distance == current.distance && candidate.depth > current.depth)
2357 || (candidate.distance == current.distance
2358 && candidate.depth == current.depth
2359 && candidate.rect.h < current.rect.h)
2360 });
2361 if replace {
2362 *best = Some(candidate);
2363 }
2364 }
2365
2366 if node.scrollable {
2367 return;
2368 }
2369 for child in &node.children {
2370 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
2371 }
2372}
2373
2374const PIN_EPSILON: f32 = 0.5;
2379
2380fn pin_would_be_active(
2388 node: &El,
2389 stored: f32,
2390 _max_offset: f32,
2391 ui_state: &UiState,
2392) -> Option<bool> {
2393 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
2394 match node.pin_policy {
2395 crate::tree::PinPolicy::None => None,
2396 crate::tree::PinPolicy::End => {
2397 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
2398 Some(match prev_active {
2399 None => true,
2400 Some(prev) => {
2401 let prev_max = prev_max.unwrap_or(0.0);
2402 if prev && stored < prev_max - PIN_EPSILON {
2403 false
2404 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
2405 true
2406 } else {
2407 prev
2408 }
2409 }
2410 })
2411 }
2412 crate::tree::PinPolicy::Start => Some(match prev_active {
2413 None => true,
2414 Some(prev) => {
2415 if prev && stored > PIN_EPSILON {
2416 false
2417 } else if !prev && stored <= PIN_EPSILON {
2418 true
2419 } else {
2420 prev
2421 }
2422 }
2423 }),
2424 }
2425}
2426
2427fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
2440 if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2441 ui_state.scroll.pin_active.remove(&node.computed_id);
2442 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
2443 return stored;
2444 }
2445 let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
2446 ui_state
2447 .scroll
2448 .pin_active
2449 .insert(node.computed_id.clone(), active);
2450 match node.pin_policy {
2451 crate::tree::PinPolicy::End => {
2452 ui_state
2453 .scroll
2454 .pin_prev_max
2455 .insert(node.computed_id.clone(), max_offset);
2456 if active { max_offset } else { stored }
2457 }
2458 crate::tree::PinPolicy::Start => {
2459 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
2463 if active { 0.0 } else { stored }
2464 }
2465 crate::tree::PinPolicy::None => unreachable!(),
2466 }
2467}
2468
2469fn resolve_ensure_visible_for_scroll(
2482 node: &El,
2483 inner: Rect,
2484 content_h: f32,
2485 ui_state: &mut UiState,
2486) -> bool {
2487 if ui_state.scroll.pending_requests.is_empty() {
2488 return false;
2489 }
2490 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
2491 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
2492 let mut wrote = false;
2493 for req in pending {
2494 let ScrollRequest::EnsureVisible {
2495 container_key,
2496 y,
2497 h,
2498 } = &req
2499 else {
2500 remaining.push(req);
2501 continue;
2502 };
2503 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
2504 remaining.push(req);
2509 continue;
2510 };
2511 let inside = node.computed_id == *ancestor_id
2514 || node
2515 .computed_id
2516 .strip_prefix(ancestor_id.as_str())
2517 .is_some_and(|rest| rest.starts_with('.'));
2518 if !inside {
2519 remaining.push(req);
2520 continue;
2521 }
2522 let current = ui_state
2523 .scroll
2524 .offsets
2525 .get(&node.computed_id)
2526 .copied()
2527 .unwrap_or(0.0);
2528 let target_top = *y;
2529 let target_bottom = *y + *h;
2530 let viewport_h = inner.h;
2531 let new_offset = if target_top < current {
2538 target_top
2539 } else if target_bottom > current + viewport_h {
2540 target_bottom - viewport_h
2541 } else {
2542 continue;
2547 };
2548 let max = (content_h - viewport_h).max(0.0);
2552 let new_offset = new_offset.clamp(0.0, max);
2553 ui_state
2554 .scroll
2555 .offsets
2556 .insert(node.computed_id.clone(), new_offset);
2557 wrote = true;
2558 }
2559 ui_state.scroll.pending_requests = remaining;
2560 wrote
2561}
2562
2563fn write_thumb_rect(
2571 node: &El,
2572 inner: Rect,
2573 content_h: f32,
2574 max_offset: f32,
2575 offset: f32,
2576 ui_state: &mut UiState,
2577) {
2578 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
2579 return;
2580 }
2581 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
2582 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
2583 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
2584 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
2585 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
2586 let track_remaining = (inner.h - thumb_h).max(0.0);
2587 let thumb_y = inner.y + track_remaining * (offset / max_offset);
2588 let edge = if node.scrollbar_gutter {
2593 inner.right() + crate::tokens::SCROLLBAR_GUTTER
2594 } else {
2595 inner.right()
2596 };
2597 let thumb_x = edge - thumb_w - track_inset;
2598 let track_x = edge - track_w - track_inset;
2599 ui_state.scroll.thumb_rects.insert(
2600 node.computed_id.clone(),
2601 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
2602 );
2603 ui_state.scroll.thumb_tracks.insert(
2604 node.computed_id.clone(),
2605 Rect::new(track_x, inner.y, track_w, inner.h),
2606 );
2607}
2608
2609fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
2610 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
2611 rect.y += dy;
2612 }
2613 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
2614 thumb.y += dy;
2615 }
2616 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
2617 track.y += dy;
2618 }
2619 for c in &node.children {
2620 shift_subtree_y(c, dy, ui_state);
2621 }
2622}
2623
2624fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
2625 let inner = node_rect.inset(node.padding);
2626 let n = node.children.len();
2627 if n == 0 {
2628 return;
2629 }
2630
2631 let total_gap = node.gap * n.saturating_sub(1) as f32;
2632 let main_extent = if vertical { inner.h } else { inner.w };
2633 let cross_extent = if vertical { inner.w } else { inner.h };
2634
2635 let intrinsics: Vec<(f32, f32)> = {
2636 crate::profile_span!("layout::axis::intrinsics");
2637 if vertical {
2638 node.children
2643 .iter()
2644 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
2645 .collect()
2646 } else {
2647 row_child_intrinsics(node, main_extent)
2657 }
2658 };
2659
2660 let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
2669 let main_intent = if vertical { c.height } else { c.width };
2670 if let Size::Aspect(r) = main_intent {
2671 let cross_intent = if vertical { c.width } else { c.height };
2672 if !matches!(cross_intent, Size::Aspect(_)) {
2673 let cross_intrinsic = if vertical { iw } else { ih };
2674 let cross_size = match cross_intent {
2675 Size::Fixed(v) => v,
2676 Size::Ch(n) => n * ch_unit(c),
2677 Size::Hug | Size::Fill(_) => match node.align {
2678 Align::Stretch => cross_extent,
2679 Align::Start | Align::Center | Align::End => cross_intrinsic,
2680 },
2681 Size::Aspect(_) => unreachable!(),
2682 };
2683 let cross_size = if vertical {
2684 clamp_w(c, cross_size)
2685 } else {
2686 clamp_h(c, cross_size)
2687 };
2688 let main = cross_size * r.max(0.0);
2689 let clamped = if vertical {
2690 clamp_h(c, main)
2691 } else {
2692 clamp_w(c, main)
2693 };
2694 return MainSize::Resolved(clamped);
2695 }
2696 }
2697 main_size_of(c, iw, ih, vertical)
2698 };
2699
2700 let mut main_sizes: Vec<f32> = Vec::with_capacity(n);
2711 let mut fill_weights: Vec<Option<f32>> = Vec::with_capacity(n);
2713 let mut consumed = 0.0;
2714 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
2715 match resolve_main(c, *iw, *ih) {
2716 MainSize::Resolved(v) => {
2717 consumed += v;
2718 main_sizes.push(v);
2719 fill_weights.push(None);
2720 }
2721 MainSize::Fill(w) => {
2722 main_sizes.push(0.0);
2723 fill_weights.push(Some(w.max(0.001)));
2724 }
2725 }
2726 }
2727 let mut remaining = (main_extent - consumed - total_gap).max(0.0);
2728 loop {
2729 let flexible_weight: f32 = fill_weights.iter().flatten().sum();
2730 if flexible_weight == 0.0 {
2731 break;
2732 }
2733 let mut frozen_any = false;
2734 let mut newly_frozen = 0.0;
2735 for (i, c) in node.children.iter().enumerate() {
2736 let Some(w) = fill_weights[i] else { continue };
2737 let raw = remaining * w / flexible_weight;
2738 let clamped = if vertical {
2739 clamp_h(c, raw)
2740 } else {
2741 clamp_w(c, raw)
2742 };
2743 main_sizes[i] = clamped;
2744 if clamped != raw {
2745 fill_weights[i] = None;
2746 frozen_any = true;
2747 newly_frozen += clamped;
2748 }
2749 }
2750 if !frozen_any {
2751 remaining = 0.0;
2753 break;
2754 }
2755 remaining = (remaining - newly_frozen).max(0.0);
2756 }
2757
2758 let free_after_used = remaining;
2762 let mut cursor = match node.justify {
2763 Justify::Start => 0.0,
2764 Justify::Center => free_after_used * 0.5,
2765 Justify::End => free_after_used,
2766 Justify::SpaceBetween => 0.0,
2767 };
2768 let between_extra = if matches!(node.justify, Justify::SpaceBetween) && n > 1 {
2769 free_after_used / (n - 1) as f32
2770 } else {
2771 0.0
2772 };
2773 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
2774
2775 crate::profile_span!("layout::axis::place");
2776 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
2777 let main_size = main_sizes[i];
2778
2779 let cross_intent = if vertical { c.width } else { c.height };
2780 let cross_intrinsic = if vertical { iw } else { ih };
2781 let cross_size = match cross_intent {
2793 Size::Fixed(v) => v,
2794 Size::Ch(n) => n * ch_unit(c),
2795 Size::Aspect(r) => main_size * r,
2796 Size::Hug | Size::Fill(_) => match node.align {
2797 Align::Stretch => cross_extent,
2798 Align::Start | Align::Center | Align::End => cross_intrinsic,
2799 },
2800 };
2801 let cross_size = if vertical {
2802 clamp_w(c, cross_size)
2803 } else {
2804 clamp_h(c, cross_size)
2805 };
2806
2807 let cross_off = match node.align {
2808 Align::Start | Align::Stretch => 0.0,
2809 Align::Center => (cross_extent - cross_size) * 0.5,
2810 Align::End => cross_extent - cross_size,
2811 };
2812
2813 let c_rect = if vertical {
2814 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
2815 } else {
2816 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
2817 };
2818 ui_state
2819 .layout
2820 .computed_rects
2821 .insert(c.computed_id.clone(), c_rect);
2822 if can_prune_scroll_child(c, c_rect, scroll_visible) {
2823 let nodes = zero_descendant_rects(c, c_rect, ui_state);
2824 record_pruned_subtree(nodes);
2825 } else {
2826 layout_children(c, c_rect, ui_state);
2827 }
2828
2829 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
2830 }
2831}
2832
2833const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
2834
2835fn scroll_visible_content_rect(
2836 node: &El,
2837 inner: Rect,
2838 vertical: bool,
2839 ui_state: &UiState,
2840) -> Option<Rect> {
2841 if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2842 return None;
2843 }
2844 let offset = ui_state
2845 .scroll
2846 .offsets
2847 .get(&node.computed_id)
2848 .copied()
2849 .unwrap_or(0.0)
2850 .max(0.0);
2851 Some(Rect::new(
2852 inner.x,
2853 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
2854 inner.w,
2855 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
2856 ))
2857}
2858
2859fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
2860 let Some(visible) = visible else {
2861 return false;
2862 };
2863 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
2864}
2865
2866fn subtree_is_layout_confined(node: &El) -> bool {
2867 if node.translate != (0.0, 0.0)
2868 || node.scale != 1.0
2869 || node.shadow > 0.0
2870 || node.paint_overflow != Sides::zero()
2871 || node.hit_overflow != Sides::zero()
2872 || node.layout_override.is_some()
2873 || node.virtual_items.is_some()
2874 {
2875 return false;
2876 }
2877 node.children.iter().all(subtree_is_layout_confined)
2878}
2879
2880fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
2881 let mut count = 0;
2882 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
2883 for child in &node.children {
2884 ui_state
2885 .layout
2886 .computed_rects
2887 .insert(child.computed_id.clone(), zero);
2888 count += 1 + zero_descendant_rects(child, zero, ui_state);
2889 }
2890 count
2891}
2892
2893fn record_pruned_subtree(nodes: u64) {
2894 INTRINSIC_CACHE.with(|cell| {
2895 if let Some(cache) = cell.borrow_mut().as_mut() {
2896 cache.prune.subtrees += 1;
2897 cache.prune.nodes += nodes;
2898 }
2899 });
2900}
2901
2902enum MainSize {
2903 Resolved(f32),
2904 Fill(f32),
2905}
2906
2907fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
2908 let s = if vertical { c.height } else { c.width };
2909 let intr = if vertical { ih } else { iw };
2910 let clamp = |v: f32| {
2911 if vertical {
2912 clamp_h(c, v)
2913 } else {
2914 clamp_w(c, v)
2915 }
2916 };
2917 match s {
2918 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
2919 Size::Ch(n) => MainSize::Resolved(clamp(n * ch_unit(c))),
2920 Size::Hug => MainSize::Resolved(clamp(intr)),
2921 Size::Fill(w) => MainSize::Fill(w),
2922 Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
2929 }
2930}
2931
2932fn child_intrinsic(
2933 c: &El,
2934 vertical: bool,
2935 parent_cross_extent: f32,
2936 parent_align: Align,
2937) -> (f32, f32) {
2938 if !vertical {
2939 return intrinsic(c);
2940 }
2941 let available_width = match c.width {
2942 Size::Fixed(v) => Some(v),
2943 Size::Ch(n) => Some(n * ch_unit(c)),
2944 Size::Fill(_) => Some(parent_cross_extent),
2945 Size::Hug => match parent_align {
2946 Align::Stretch => Some(parent_cross_extent),
2947 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
2948 },
2949 Size::Aspect(_) => Some(parent_cross_extent),
2955 };
2956 intrinsic_constrained(c, available_width)
2957}
2958
2959fn row_child_intrinsics(node: &El, inner_main_extent: f32) -> Vec<(f32, f32)> {
2968 let n = node.children.len();
2969 let total_gap = node.gap * n.saturating_sub(1) as f32;
2970
2971 let mut first: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2972 let mut consumed: f32 = 0.0;
2973 let mut fill_weight_total: f32 = 0.0;
2974 for c in &node.children {
2975 match c.width {
2976 Size::Fill(w) => {
2977 fill_weight_total += w.max(0.001);
2978 first.push(None);
2979 }
2980 _ => {
2981 let (iw, ih) = intrinsic(c);
2982 consumed += iw;
2983 first.push(Some((iw, ih)));
2984 }
2985 }
2986 }
2987
2988 let fill_remaining = (inner_main_extent - consumed - total_gap).max(0.0);
2989
2990 node.children
2991 .iter()
2992 .zip(first)
2993 .map(|(c, slot)| match slot {
2994 Some(rc) => rc,
2995 None => {
2996 let weight = match c.width {
2997 Size::Fill(w) => w.max(0.001),
2998 _ => 1.0,
2999 };
3000 let av = if fill_weight_total > 0.0 {
3001 fill_remaining * weight / fill_weight_total
3002 } else {
3003 fill_remaining
3004 };
3005 intrinsic_constrained(c, Some(av))
3006 }
3007 })
3008 .collect()
3009}
3010
3011fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
3012 let constrained_width = match c.width {
3019 Size::Fixed(v) => Some(v),
3020 Size::Ch(n) => Some(n * ch_unit(c)),
3021 Size::Fill(_) | Size::Hug => Some(parent.w),
3022 Size::Aspect(_) => None,
3025 };
3026 let (iw, ih) = intrinsic_constrained(c, constrained_width);
3027 let (w, h) = match (c.width, c.height) {
3032 (Size::Aspect(_), Size::Aspect(_)) => (iw.min(parent.w), ih.min(parent.h)),
3033 (Size::Aspect(r), _) => {
3034 let h = match c.height {
3035 Size::Fixed(v) => v,
3036 Size::Ch(n) => n * ch_unit(c),
3037 Size::Hug => ih.min(parent.h),
3038 Size::Fill(_) => parent.h,
3039 Size::Aspect(_) => unreachable!(),
3040 };
3041 (h * r, h)
3042 }
3043 (_, Size::Aspect(r)) => {
3044 let w = match c.width {
3045 Size::Fixed(v) => v,
3046 Size::Ch(n) => n * ch_unit(c),
3047 Size::Hug => iw.min(parent.w),
3048 Size::Fill(_) => parent.w,
3049 Size::Aspect(_) => unreachable!(),
3050 };
3051 (w, w * r)
3052 }
3053 _ => {
3054 let w = match c.width {
3055 Size::Fixed(v) => v,
3056 Size::Ch(n) => n * ch_unit(c),
3057 Size::Hug => iw.min(parent.w),
3058 Size::Fill(_) => parent.w,
3059 Size::Aspect(_) => unreachable!(),
3060 };
3061 let h = match c.height {
3062 Size::Fixed(v) => v,
3063 Size::Ch(n) => n * ch_unit(c),
3064 Size::Hug => ih.min(parent.h),
3065 Size::Fill(_) => parent.h,
3066 Size::Aspect(_) => unreachable!(),
3067 };
3068 (w, h)
3069 }
3070 };
3071 let w = clamp_w(c, w);
3072 let h = clamp_h(c, h);
3073 let x = match align {
3074 Align::Start | Align::Stretch => parent.x,
3075 Align::Center => parent.x + (parent.w - w) * 0.5,
3076 Align::End => parent.right() - w,
3077 };
3078 let y = match justify {
3079 Justify::Start | Justify::SpaceBetween => parent.y,
3080 Justify::Center => parent.y + (parent.h - h) * 0.5,
3081 Justify::End => parent.bottom() - h,
3082 };
3083 Rect::new(x, y, w, h)
3084}
3085
3086pub fn intrinsic(c: &El) -> (f32, f32) {
3088 intrinsic_constrained(c, None)
3089}
3090
3091fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
3092 let available_width = match c.width {
3098 Size::Fixed(v) => Some(v),
3099 _ => available_width,
3100 };
3101 let key = intrinsic_cache_key(c, available_width);
3102 if let Some(key) = &key
3103 && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
3104 let mut slot = cell.borrow_mut();
3105 let cache = slot.as_mut()?;
3106 let cached = cache.measurements.get(key).copied();
3107 if cached.is_some() {
3108 cache.stats.hits += 1;
3109 }
3110 cached
3111 })
3112 {
3113 return cached;
3114 }
3115
3116 if key.is_some() {
3117 INTRINSIC_CACHE.with(|cell| {
3118 if let Some(cache) = cell.borrow_mut().as_mut() {
3119 cache.stats.misses += 1;
3120 }
3121 });
3122 }
3123
3124 let measured = apply_aspect(
3125 c,
3126 available_width,
3127 intrinsic_constrained_uncached(c, available_width),
3128 );
3129
3130 if let Some(key) = key {
3131 INTRINSIC_CACHE.with(|cell| {
3132 if let Some(cache) = cell.borrow_mut().as_mut() {
3133 cache.measurements.insert(key, measured);
3134 }
3135 });
3136 }
3137
3138 measured
3139}
3140
3141fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
3156 match (c.width, c.height) {
3157 (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
3158 (Size::Aspect(r), _) => {
3159 (clamp_w(c, ih * r.max(0.0)), ih)
3164 }
3165 (_, Size::Aspect(r)) => {
3166 let raw_basis = match c.width {
3167 Size::Fixed(v) => v,
3168 Size::Ch(n) => n * ch_unit(c),
3169 Size::Fill(_) => available_width.unwrap_or(iw),
3170 Size::Hug | Size::Aspect(_) => iw,
3171 };
3172 let basis = clamp_w(c, raw_basis);
3176 (iw, clamp_h(c, basis * r.max(0.0)))
3177 }
3178 _ => (iw, ih),
3179 }
3180}
3181
3182fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
3183 if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
3184 return None;
3185 }
3186 if c.computed_id.is_empty() {
3187 return None;
3188 }
3189 Some(IntrinsicCacheKey {
3190 computed_id: c.computed_id.clone(),
3191 available_width_bits: available_width.map(f32::to_bits),
3192 })
3193}
3194
3195fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
3196 if c.layout_override.is_some() {
3197 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3202 panic!(
3203 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
3204 Size::Hug is not supported for custom layouts",
3205 c.computed_id,
3206 );
3207 }
3208 return apply_min(c, 0.0, 0.0);
3209 }
3210 if c.virtual_items.is_some() {
3211 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3216 panic!(
3217 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
3218 Size::Hug would defeat virtualization",
3219 c.computed_id,
3220 );
3221 }
3222 return apply_min(c, 0.0, 0.0);
3223 }
3224 if matches!(c.kind, Kind::Inlines) {
3225 return inline_paragraph_intrinsic(c, available_width);
3226 }
3227 if matches!(c.kind, Kind::HardBreak) {
3228 return apply_min(c, 0.0, 0.0);
3232 }
3233 if matches!(c.kind, Kind::Math) {
3234 if let Some(expr) = &c.math {
3235 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
3236 return apply_min(
3237 c,
3238 layout.width + c.padding.left + c.padding.right,
3239 layout.height() + c.padding.top + c.padding.bottom,
3240 );
3241 }
3242 return apply_min(c, 0.0, 0.0);
3243 }
3244 if c.icon.is_some() {
3245 return apply_min(
3246 c,
3247 c.font_size + c.padding.left + c.padding.right,
3248 c.font_size + c.padding.top + c.padding.bottom,
3249 );
3250 }
3251 if let Some(img) = &c.image {
3252 let w = img.width() as f32 + c.padding.left + c.padding.right;
3256 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
3257 return apply_min(c, w, h);
3258 }
3259 if let Some(text) = &c.text {
3260 let content_available = match c.text_wrap {
3261 TextWrap::NoWrap => None,
3262 TextWrap::Wrap => available_width
3263 .or(match c.width {
3264 Size::Fixed(v) => Some(v),
3265 Size::Ch(n) => Some(n * ch_unit(c)),
3266 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3270 })
3271 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3272 };
3273 let display = display_text_for_measure(c, text, content_available);
3274 let layout = text_metrics::layout_text_with_line_height_and_family(
3275 &display,
3276 c.font_size,
3277 c.line_height,
3278 c.font_family,
3279 c.font_weight,
3280 c.font_mono,
3281 c.text_tabular_numerals,
3282 c.text_wrap,
3283 content_available,
3284 );
3285 let w = match (content_available, c.width) {
3286 (Some(available), Size::Hug | Size::Aspect(_)) => {
3287 let unwrapped = text_metrics::layout_text_with_family(
3288 text,
3289 c.font_size,
3290 c.font_family,
3291 c.font_weight,
3292 c.font_mono,
3293 c.text_tabular_numerals,
3294 TextWrap::NoWrap,
3295 None,
3296 );
3297 unwrapped.width.min(available) + c.padding.left + c.padding.right
3298 }
3299 (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3300 available + c.padding.left + c.padding.right
3301 }
3302 (None, _) => layout.width + c.padding.left + c.padding.right,
3303 };
3304 let h = layout.height + c.padding.top + c.padding.bottom;
3305 return apply_min(c, w, h);
3306 }
3307 match c.axis {
3308 Axis::Overlay => {
3309 let mut w: f32 = 0.0;
3310 let mut h: f32 = 0.0;
3311 for ch in &c.children {
3312 let child_available =
3313 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3314 let (cw, chh) = intrinsic_constrained(ch, child_available);
3315 w = w.max(cw);
3316 h = h.max(chh);
3317 }
3318 apply_min(
3319 c,
3320 w + c.padding.left + c.padding.right,
3321 h + c.padding.top + c.padding.bottom,
3322 )
3323 }
3324 Axis::Column => {
3325 let mut w: f32 = 0.0;
3326 let mut h: f32 = c.padding.top + c.padding.bottom;
3327 let n = c.children.len();
3328 let child_available =
3329 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3330 for (i, ch) in c.children.iter().enumerate() {
3331 let (cw, chh) = intrinsic_constrained(ch, child_available);
3332 w = w.max(cw);
3333 h += chh;
3334 if i + 1 < n {
3335 h += c.gap;
3336 }
3337 }
3338 apply_min(c, w + c.padding.left + c.padding.right, h)
3339 }
3340 Axis::Row => {
3341 let n = c.children.len();
3351 let total_gap = c.gap * n.saturating_sub(1) as f32;
3352 let inner_available = available_width
3353 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
3354
3355 let mut consumed: f32 = 0.0;
3361 let mut fill_weight_total: f32 = 0.0;
3362 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
3363 for ch in &c.children {
3364 match ch.width {
3365 Size::Fill(w) => {
3366 fill_weight_total += w.max(0.001);
3367 sizes.push(None);
3368 }
3369 _ => {
3370 let (cw, chh) = intrinsic(ch);
3371 consumed += cw;
3372 sizes.push(Some((cw, chh)));
3373 }
3374 }
3375 }
3376
3377 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3385 let mut w_total: f32 = c.padding.left + c.padding.right;
3386 let mut h_max: f32 = 0.0;
3387 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
3388 let (cw, chh) = match slot {
3389 Some(rc) => rc,
3390 None => match (fill_remaining, fill_weight_total > 0.0) {
3391 (Some(av), true) => {
3392 let weight = match ch.width {
3393 Size::Fill(w) => w.max(0.001),
3394 _ => 1.0,
3395 };
3396 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
3397 }
3398 _ => intrinsic(ch),
3399 },
3400 };
3401 w_total += cw;
3402 if i + 1 < n {
3403 w_total += c.gap;
3404 }
3405 h_max = h_max.max(chh);
3406 }
3407 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
3408 }
3409 }
3410}
3411
3412pub(crate) fn text_layout(
3413 c: &El,
3414 available_width: Option<f32>,
3415) -> Option<text_metrics::TextLayout> {
3416 let text = c.text.as_ref()?;
3417 let content_available = match c.text_wrap {
3418 TextWrap::NoWrap => None,
3419 TextWrap::Wrap => available_width
3420 .or(match c.width {
3421 Size::Fixed(v) => Some(v),
3422 Size::Ch(n) => Some(n * ch_unit(c)),
3423 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3424 })
3425 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3426 };
3427 let display = display_text_for_measure(c, text, content_available);
3428 Some(text_metrics::layout_text_with_line_height_and_family(
3429 &display,
3430 c.font_size,
3431 c.line_height,
3432 c.font_family,
3433 c.font_weight,
3434 c.font_mono,
3435 c.text_tabular_numerals,
3436 c.text_wrap,
3437 content_available,
3438 ))
3439}
3440
3441fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
3442 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
3443 (c.text_wrap, c.text_max_lines, available_width)
3444 {
3445 text_metrics::clamp_text_to_lines_with_family(
3446 text,
3447 c.font_size,
3448 c.font_family,
3449 c.font_weight,
3450 c.font_mono,
3451 width,
3452 max_lines,
3453 )
3454 } else {
3455 text.to_string()
3456 }
3457}
3458
3459fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
3460 if let Size::Fixed(v) = c.width {
3461 w = v;
3462 }
3463 if let Size::Fixed(v) = c.height {
3464 h = v;
3465 }
3466 (clamp_w(c, w), clamp_h(c, h))
3467}
3468
3469pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
3475 if let Some(max_w) = c.max_width {
3476 w = w.min(max_w);
3477 }
3478 if let Some(min_w) = c.min_width {
3479 w = w.max(min_w);
3480 }
3481 w.max(0.0)
3482}
3483
3484pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
3486 if let Some(max_h) = c.max_height {
3487 h = h.min(max_h);
3488 }
3489 if let Some(min_h) = c.min_height {
3490 h = h.max(min_h);
3491 }
3492 h.max(0.0)
3493}
3494
3495fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3508 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
3509 return inline_mixed_intrinsic(node, available_width);
3510 }
3511 let concat = concat_inline_text(&node.children);
3512 let size = inline_paragraph_size(node);
3513 let line_height = inline_paragraph_line_height(node);
3514 let content_available = match node.text_wrap {
3515 TextWrap::NoWrap => None,
3516 TextWrap::Wrap => available_width
3517 .or(match node.width {
3518 Size::Fixed(v) => Some(v),
3519 Size::Ch(n) => Some(n * ch_unit(node)),
3520 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3521 })
3522 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
3523 };
3524 let layout = text_metrics::layout_text_with_line_height_and_family(
3525 &concat,
3526 size,
3527 line_height,
3528 node.font_family,
3529 FontWeight::Regular,
3530 false,
3531 false,
3532 node.text_wrap,
3533 content_available,
3534 );
3535 let w = match (content_available, node.width) {
3536 (Some(available), Size::Hug | Size::Aspect(_)) => {
3537 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
3538 &concat,
3539 size,
3540 line_height,
3541 node.font_family,
3542 FontWeight::Regular,
3543 false,
3544 false,
3545 TextWrap::NoWrap,
3546 None,
3547 );
3548 unwrapped.width.min(available) + node.padding.left + node.padding.right
3549 }
3550 (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3551 available + node.padding.left + node.padding.right
3552 }
3553 (None, _) => layout.width + node.padding.left + node.padding.right,
3554 };
3555 let h = layout.height + node.padding.top + node.padding.bottom;
3556 apply_min(node, w, h)
3557}
3558
3559fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3560 let wrap_width = match node.text_wrap {
3561 TextWrap::Wrap => available_width.or(match node.width {
3562 Size::Fixed(v) => Some(v),
3563 Size::Ch(n) => Some(n * ch_unit(node)),
3564 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3565 }),
3566 TextWrap::NoWrap => None,
3567 }
3568 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
3569
3570 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
3571 node.text_wrap,
3572 wrap_width,
3573 node.font_size * 0.82,
3574 node.font_size * 0.22,
3575 node.line_height,
3576 );
3577
3578 for child in &node.children {
3579 match child.kind {
3580 Kind::HardBreak => {
3581 breaker.finish_line();
3582 continue;
3583 }
3584 Kind::Text => {
3585 let text = child.text.as_deref().unwrap_or("");
3586 for chunk in inline_text_chunks(text) {
3587 let is_space = chunk.chars().all(char::is_whitespace);
3588 if breaker.skips_leading_space(is_space) {
3589 continue;
3590 }
3591 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
3592 if breaker.wraps_before(is_space, w) {
3593 breaker.finish_line();
3594 }
3595 if breaker.skips_overflowing_space(is_space, w) {
3596 continue;
3597 }
3598 breaker.push(w, ascent, descent);
3599 }
3600 continue;
3601 }
3602 _ => {}
3603 }
3604 let (w, ascent, descent) = inline_child_metrics(child);
3605 if breaker.wraps_before(false, w) {
3606 breaker.finish_line();
3607 }
3608 breaker.push(w, ascent, descent);
3609 }
3610 let measurement = breaker.finish();
3611 let w = measurement.width + node.padding.left + node.padding.right;
3612 let h = measurement.height + node.padding.top + node.padding.bottom;
3613 apply_min(node, w, h)
3614}
3615
3616fn inline_text_chunks(text: &str) -> Vec<&str> {
3617 let mut chunks = Vec::new();
3618 let mut start = 0;
3619 let mut last_space = None;
3620 for (i, ch) in text.char_indices() {
3621 let is_space = ch.is_whitespace();
3622 match last_space {
3623 None => last_space = Some(is_space),
3624 Some(prev) if prev != is_space => {
3625 chunks.push(&text[start..i]);
3626 start = i;
3627 last_space = Some(is_space);
3628 }
3629 _ => {}
3630 }
3631 }
3632 if start < text.len() {
3633 chunks.push(&text[start..]);
3634 }
3635 chunks
3636}
3637
3638fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
3639 let layout = text_metrics::layout_text_with_line_height_and_family(
3640 text,
3641 child.font_size,
3642 child.line_height,
3643 child.font_family,
3644 child.font_weight,
3645 child.font_mono,
3646 child.text_tabular_numerals,
3647 TextWrap::NoWrap,
3648 None,
3649 );
3650 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
3651}
3652
3653fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
3654 match child.kind {
3655 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
3656 Kind::Math => {
3657 if let Some(expr) = &child.math {
3658 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
3659 (layout.width, layout.ascent, layout.descent)
3660 } else {
3661 (0.0, 0.0, 0.0)
3662 }
3663 }
3664 _ => (0.0, 0.0, 0.0),
3665 }
3666}
3667
3668fn concat_inline_text(children: &[El]) -> String {
3675 let mut s = String::new();
3676 for c in children {
3677 match c.kind {
3678 Kind::Text => {
3679 if let Some(t) = &c.text {
3680 s.push_str(t);
3681 }
3682 }
3683 Kind::HardBreak => s.push('\n'),
3684 _ => {}
3685 }
3686 }
3687 s
3688}
3689
3690fn inline_paragraph_size(node: &El) -> f32 {
3694 let mut size: f32 = node.font_size;
3695 for c in &node.children {
3696 if matches!(c.kind, Kind::Text) {
3697 size = size.max(c.font_size);
3698 }
3699 }
3700 size
3701}
3702
3703fn inline_paragraph_line_height(node: &El) -> f32 {
3704 let mut line_height: f32 = node.line_height;
3705 let mut max_size: f32 = node.font_size;
3706 for c in &node.children {
3707 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
3708 max_size = c.font_size;
3709 line_height = c.line_height;
3710 }
3711 }
3712 line_height
3713}
3714
3715#[cfg(test)]
3716mod tests {
3717 use super::*;
3718 use crate::state::UiState;
3719
3720 #[test]
3725 fn duplicate_sibling_keys_flagged_once() {
3726 let mut root = column([
3727 crate::widgets::text::text("a").key("dup"),
3728 crate::widgets::text::text("b").key("dup"),
3729 ]);
3730 let mut state = UiState::new();
3731 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3732
3733 assert_eq!(root.children[0].computed_id, root.children[1].computed_id);
3735 let id = root.children[0].computed_id.clone();
3736 assert!(state.layout.warned_duplicate_ids.contains(&id));
3738
3739 let n_before = state.layout.warned_duplicate_ids.len();
3741 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3742 assert_eq!(state.layout.warned_duplicate_ids.len(), n_before);
3743 }
3744
3745 #[test]
3747 fn distinct_sibling_keys_not_flagged() {
3748 let mut root = column([
3749 crate::widgets::text::text("a").key("one"),
3750 crate::widgets::text::text("b").key("two"),
3751 ]);
3752 let mut state = UiState::new();
3753 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3754 assert!(state.layout.warned_duplicate_ids.is_empty());
3755 }
3756
3757 #[test]
3762 fn align_center_shrinks_fill_child_to_intrinsic() {
3763 let mut root = column([crate::row([crate::widgets::text::text("hi")
3767 .width(Size::Fixed(40.0))
3768 .height(Size::Fixed(20.0))])])
3769 .align(Align::Center)
3770 .width(Size::Fixed(200.0))
3771 .height(Size::Fixed(100.0));
3772 let mut state = UiState::new();
3773 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3774 let row_rect = state.rect(&root.children[0].computed_id);
3775 assert!(
3778 (row_rect.x - 80.0).abs() < 0.5,
3779 "expected x≈80 (centered), got {}",
3780 row_rect.x
3781 );
3782 assert!(
3783 (row_rect.w - 40.0).abs() < 0.5,
3784 "expected w≈40 (shrunk to intrinsic), got {}",
3785 row_rect.w
3786 );
3787 }
3788
3789 #[test]
3793 fn max_clamped_fill_frees_space_to_sibling_fills() {
3794 let mut root = crate::row([
3795 El::new(Kind::Group).width(Size::Fill(1.0)).max_width(50.0),
3796 El::new(Kind::Group).width(Size::Fill(1.0)),
3797 ])
3798 .width(Size::Fixed(400.0))
3799 .height(Size::Fixed(100.0));
3800 let mut state = UiState::new();
3801 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3802 let a = state.rect(&root.children[0].computed_id);
3803 let b = state.rect(&root.children[1].computed_id);
3804 assert!((a.w - 50.0).abs() < 0.5, "capped fill w={}", a.w);
3805 assert!(
3806 (b.w - 350.0).abs() < 0.5,
3807 "sibling fill should absorb the freed 150px, got w={}",
3808 b.w
3809 );
3810 }
3811
3812 #[test]
3816 fn min_clamped_fill_steals_space_from_sibling_fills() {
3817 let mut root = crate::row([
3818 El::new(Kind::Group).width(Size::Fill(1.0)).min_width(300.0),
3819 El::new(Kind::Group).width(Size::Fill(1.0)),
3820 ])
3821 .width(Size::Fixed(400.0))
3822 .height(Size::Fixed(100.0));
3823 let mut state = UiState::new();
3824 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3825 let a = state.rect(&root.children[0].computed_id);
3826 let b = state.rect(&root.children[1].computed_id);
3827 assert!((a.w - 300.0).abs() < 0.5, "floored fill w={}", a.w);
3828 assert!(
3829 (b.w - 100.0).abs() < 0.5,
3830 "sibling fill should shrink to the remaining 100px, got w={}",
3831 b.w
3832 );
3833 }
3834
3835 #[test]
3839 fn justify_distributes_space_left_by_fully_capped_fills() {
3840 let mut root = crate::row([El::new(Kind::Group).width(Size::Fill(1.0)).max_width(100.0)])
3841 .justify(Justify::Center)
3842 .width(Size::Fixed(400.0))
3843 .height(Size::Fixed(100.0));
3844 let mut state = UiState::new();
3845 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
3846 let a = state.rect(&root.children[0].computed_id);
3847 assert!((a.w - 100.0).abs() < 0.5, "capped fill w={}", a.w);
3848 assert!(
3849 (a.x - 150.0).abs() < 0.5,
3850 "capped fill should be centered (x≈150), got x={}",
3851 a.x
3852 );
3853 }
3854
3855 #[test]
3858 fn align_stretch_preserves_fill_stretch() {
3859 let mut root = column([crate::row([crate::widgets::text::text("hi")
3860 .width(Size::Fixed(40.0))
3861 .height(Size::Fixed(20.0))])])
3862 .align(Align::Stretch)
3863 .width(Size::Fixed(200.0))
3864 .height(Size::Fixed(100.0));
3865 let mut state = UiState::new();
3866 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
3867 let row_rect = state.rect(&root.children[0].computed_id);
3868 assert!(
3869 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
3870 "expected stretched (x=0, w=200), got x={} w={}",
3871 row_rect.x,
3872 row_rect.w
3873 );
3874 }
3875
3876 #[test]
3883 fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
3884 let long = "The quick brown fox jumps over the lazy dog, then \
3885 does it again and again until the line is long \
3886 enough to wrap several times.";
3887 let mut root = column([column([column([crate::widgets::text::text(long)
3891 .wrap_text()
3892 .width(Size::Fixed(200.0))])
3893 .width(Size::Fixed(240.0))])
3894 .width(Size::Fill(1.0))]);
3895 let mut state = UiState::new();
3896 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
3897 let card = state.rect(&root.children[0].computed_id);
3898 let text_rect = state.rect(&root.children[0].children[0].children[0].computed_id);
3899 assert!(
3900 text_rect.h > 25.0,
3901 "text should wrap to multiple lines at 200px, got h={}",
3902 text_rect.h
3903 );
3904 assert!(
3905 (card.h - text_rect.h).abs() < 0.5,
3906 "Hug card height {} must match wrapped text height {}",
3907 card.h,
3908 text_rect.h
3909 );
3910 }
3911
3912 #[test]
3915 fn justify_center_centers_hug_children() {
3916 let mut root = column([crate::widgets::text::text("hi")
3917 .width(Size::Fixed(40.0))
3918 .height(Size::Fixed(20.0))])
3919 .justify(Justify::Center)
3920 .height(Size::Fill(1.0));
3921 let mut state = UiState::new();
3922 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3923 let child_rect = state.rect(&root.children[0].computed_id);
3924 assert!(
3926 (child_rect.y - 40.0).abs() < 0.5,
3927 "expected y≈40, got {}",
3928 child_rect.y
3929 );
3930 }
3931
3932 #[test]
3933 fn justify_end_pushes_to_bottom() {
3934 let mut root = column([crate::widgets::text::text("hi")
3935 .width(Size::Fixed(40.0))
3936 .height(Size::Fixed(20.0))])
3937 .justify(Justify::End)
3938 .height(Size::Fill(1.0));
3939 let mut state = UiState::new();
3940 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3941 let child_rect = state.rect(&root.children[0].computed_id);
3942 assert!(
3943 (child_rect.y - 80.0).abs() < 0.5,
3944 "expected y≈80, got {}",
3945 child_rect.y
3946 );
3947 }
3948
3949 #[test]
3953 fn justify_space_between_distributes_evenly() {
3954 let row_child = || {
3955 crate::widgets::text::text("x")
3956 .width(Size::Fixed(20.0))
3957 .height(Size::Fixed(20.0))
3958 };
3959 let mut root = column([row_child(), row_child(), row_child()])
3960 .justify(Justify::SpaceBetween)
3961 .height(Size::Fixed(200.0));
3962 let mut state = UiState::new();
3963 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
3964 let y0 = state.rect(&root.children[0].computed_id).y;
3967 let y1 = state.rect(&root.children[1].computed_id).y;
3968 let y2 = state.rect(&root.children[2].computed_id).y;
3969 assert!(
3970 y0.abs() < 0.5,
3971 "first child should be flush at y=0, got {y0}"
3972 );
3973 assert!(
3974 (y1 - 90.0).abs() < 0.5,
3975 "middle child should be at y≈90, got {y1}"
3976 );
3977 assert!(
3978 (y2 - 180.0).abs() < 0.5,
3979 "last child should be flush at y≈180, got {y2}"
3980 );
3981 }
3982
3983 #[test]
3987 fn fill_weight_distributes_proportionally() {
3988 let big = crate::widgets::text::text("big")
3989 .width(Size::Fixed(40.0))
3990 .height(Size::Fill(2.0));
3991 let small = crate::widgets::text::text("small")
3992 .width(Size::Fixed(40.0))
3993 .height(Size::Fill(1.0));
3994 let mut root = column([big, small]).height(Size::Fixed(300.0));
3995 let mut state = UiState::new();
3996 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
3997 let big_h = state.rect(&root.children[0].computed_id).h;
3999 let small_h = state.rect(&root.children[1].computed_id).h;
4000 assert!(
4001 (big_h - 200.0).abs() < 0.5,
4002 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
4003 );
4004 assert!(
4005 (small_h - 100.0).abs() < 0.5,
4006 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
4007 );
4008 }
4009
4010 #[test]
4014 fn padding_on_hug_includes_in_intrinsic() {
4015 let root = column([crate::widgets::text::text("x")
4016 .width(Size::Fixed(40.0))
4017 .height(Size::Fixed(40.0))])
4018 .padding(Sides::all(20.0));
4019 let (w, h) = intrinsic(&root);
4020 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
4022 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
4023 }
4024
4025 #[test]
4029 fn align_end_pins_to_cross_axis_far_edge() {
4030 let mut root = crate::row([crate::widgets::text::text("hi")
4031 .width(Size::Fixed(40.0))
4032 .height(Size::Fixed(20.0))])
4033 .align(Align::End)
4034 .width(Size::Fixed(200.0))
4035 .height(Size::Fixed(100.0));
4036 let mut state = UiState::new();
4037 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4038 let child_rect = state.rect(&root.children[0].computed_id);
4039 assert!(
4041 (child_rect.y - 80.0).abs() < 0.5,
4042 "expected y≈80 (pinned to bottom), got {}",
4043 child_rect.y
4044 );
4045 }
4046
4047 #[test]
4048 fn overlay_can_center_hug_child() {
4049 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
4050 .width(Size::Fixed(200.0))
4051 .height(Size::Hug)])
4052 .align(Align::Center)
4053 .justify(Justify::Center);
4054 let mut state = UiState::new();
4055 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
4056 let child_rect = state.rect(&root.children[0].computed_id);
4057 assert!(
4058 (child_rect.x - 200.0).abs() < 0.5,
4059 "expected x≈200, got {}",
4060 child_rect.x
4061 );
4062 assert!(
4063 child_rect.y > 100.0 && child_rect.y < 200.0,
4064 "expected centered y, got {}",
4065 child_rect.y
4066 );
4067 }
4068
4069 #[test]
4070 fn scroll_offset_translates_children_and_clamps_to_content() {
4071 let mut root = scroll(
4075 (0..6)
4076 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4077 )
4078 .key("list")
4079 .gap(12.0)
4080 .height(Size::Fixed(200.0));
4081 let mut state = UiState::new();
4082 assign_ids(&mut root);
4083 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
4084
4085 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4086
4087 let stored = state
4089 .scroll
4090 .offsets
4091 .get(&root.computed_id)
4092 .copied()
4093 .unwrap_or(0.0);
4094 assert!(
4095 (stored - 80.0).abs() < 0.01,
4096 "offset clamped unexpectedly: {stored}"
4097 );
4098 let c0 = state.rect(&root.children[0].computed_id);
4100 assert!(
4101 (c0.y - (-80.0)).abs() < 0.01,
4102 "child 0 y = {} (expected -80)",
4103 c0.y
4104 );
4105 state
4107 .scroll
4108 .offsets
4109 .insert(root.computed_id.clone(), 9999.0);
4110 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4111 let stored = state
4112 .scroll
4113 .offsets
4114 .get(&root.computed_id)
4115 .copied()
4116 .unwrap_or(0.0);
4117 assert!(
4118 (stored - 160.0).abs() < 0.01,
4119 "overshoot clamped to {stored}"
4120 );
4121 let mut tiny =
4123 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
4124 .height(Size::Fixed(200.0));
4125 let mut tiny_state = UiState::new();
4126 assign_ids(&mut tiny);
4127 tiny_state
4128 .scroll
4129 .offsets
4130 .insert(tiny.computed_id.clone(), 50.0);
4131 layout(
4132 &mut tiny,
4133 &mut tiny_state,
4134 Rect::new(0.0, 0.0, 300.0, 200.0),
4135 );
4136 assert_eq!(
4137 tiny_state
4138 .scroll
4139 .offsets
4140 .get(&tiny.computed_id)
4141 .copied()
4142 .unwrap_or(0.0),
4143 0.0
4144 );
4145 }
4146
4147 #[test]
4148 fn scroll_layout_prunes_far_offscreen_descendants() {
4149 let far = column([crate::widgets::text::text("far row body").key("far-text")])
4150 .height(Size::Fixed(40.0));
4151 let mut root = scroll([
4152 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
4153 crate::tree::spacer().height(Size::Fixed(400.0)),
4154 far,
4155 ])
4156 .key("list")
4157 .height(Size::Fixed(80.0));
4158 let mut state = UiState::new();
4159 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
4160 let stats = take_prune_stats();
4161
4162 assert!(
4163 stats.subtrees >= 1,
4164 "expected at least one far scroll child to be pruned, got {stats:?}"
4165 );
4166 assert!(
4167 stats.nodes >= 1,
4168 "expected pruned descendants to be zeroed, got {stats:?}"
4169 );
4170 let far_text = state
4171 .rect_of_key("far-text")
4172 .expect("far text keeps a zero rect while pruned");
4173 assert_eq!(far_text.w, 0.0);
4174 assert_eq!(far_text.h, 0.0);
4175 }
4176
4177 #[test]
4178 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
4179 let make_root = || {
4180 let paragraph_text = "Variable width text wraps into a different number of lines when \
4181 the viewport narrows, which used to make a plain scroll box lose \
4182 the item the user was reading.";
4183 scroll([column((0..30).map(|i| {
4184 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
4185 .key(format!("paragraph-{i}"))
4186 }))
4187 .gap(8.0)])
4188 .key("article")
4189 .height(Size::Fixed(180.0))
4190 };
4191
4192 let mut root = make_root();
4193 let mut state = UiState::new();
4194 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4195
4196 state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
4197 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4198
4199 let anchor = state
4200 .scroll
4201 .scroll_anchors
4202 .get(&root.computed_id)
4203 .cloned()
4204 .expect("plain scroll should store a visible descendant anchor");
4205 let before_rect = state.rect(&anchor.node_id);
4206 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
4207 let before_offset = state.scroll_offset(&root.computed_id);
4208
4209 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
4210
4211 let after_rect = state.rect(&anchor.node_id);
4212 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
4213 let after_offset = state.scroll_offset(&root.computed_id);
4214 assert!(
4215 (after_anchor_y - before_anchor_y).abs() < 0.5,
4216 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
4217 );
4218 assert!(
4219 (after_offset - before_offset).abs() > 20.0,
4220 "offset should absorb height changes above the anchor"
4221 );
4222 }
4223
4224 #[test]
4225 fn scrollbar_thumb_size_and_position_track_overflow() {
4226 let mut root = scroll(
4229 (0..6)
4230 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4231 )
4232 .gap(12.0)
4233 .height(Size::Fixed(200.0));
4234 let mut state = UiState::new();
4235 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4236
4237 let metrics = state
4238 .scroll
4239 .metrics
4240 .get(&root.computed_id)
4241 .copied()
4242 .expect("scrollable should have metrics");
4243 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
4244 assert!((metrics.content_h - 360.0).abs() < 0.01);
4245 assert!((metrics.max_offset - 160.0).abs() < 0.01);
4246
4247 let thumb = state
4248 .scroll
4249 .thumb_rects
4250 .get(&root.computed_id)
4251 .copied()
4252 .expect("scrollable with scrollbar() and overflow gets a thumb");
4253 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
4255 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
4256 assert!(thumb.y.abs() < 0.01);
4258 assert!(
4260 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
4261 "thumb anchored at {} (expected {})",
4262 thumb.x,
4263 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
4264 );
4265
4266 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
4268 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4269 let thumb = state
4270 .scroll
4271 .thumb_rects
4272 .get(&root.computed_id)
4273 .copied()
4274 .unwrap();
4275 let track_remaining = 200.0 - thumb.h;
4276 let expected_y = track_remaining * (80.0 / 160.0);
4277 assert!(
4278 (thumb.y - expected_y).abs() < 0.5,
4279 "thumb at half-scroll y = {} (expected {expected_y})",
4280 thumb.y,
4281 );
4282 }
4283
4284 #[test]
4285 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
4286 let mut root = scroll(
4290 (0..6)
4291 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4292 )
4293 .gap(12.0)
4294 .height(Size::Fixed(200.0));
4295 let mut state = UiState::new();
4296 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4297
4298 let thumb = state
4299 .scroll
4300 .thumb_rects
4301 .get(&root.computed_id)
4302 .copied()
4303 .unwrap();
4304 let track = state
4305 .scroll
4306 .thumb_tracks
4307 .get(&root.computed_id)
4308 .copied()
4309 .unwrap();
4310 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
4312 assert!(
4313 (track.right() - thumb.right()).abs() < 0.01,
4314 "track and thumb must share the right edge",
4315 );
4316 assert!(
4319 (track.h - 200.0).abs() < 0.01,
4320 "track height = {} (expected 200)",
4321 track.h,
4322 );
4323 }
4324
4325 #[test]
4326 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
4327 let mut suppressed = scroll(
4329 (0..6)
4330 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4331 )
4332 .no_scrollbar()
4333 .height(Size::Fixed(200.0));
4334 let mut state = UiState::new();
4335 layout(
4336 &mut suppressed,
4337 &mut state,
4338 Rect::new(0.0, 0.0, 300.0, 200.0),
4339 );
4340 assert!(
4341 !state
4342 .scroll
4343 .thumb_rects
4344 .contains_key(&suppressed.computed_id)
4345 );
4346
4347 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
4349 .height(Size::Fixed(200.0));
4350 let mut tiny_state = UiState::new();
4351 layout(
4352 &mut tiny,
4353 &mut tiny_state,
4354 Rect::new(0.0, 0.0, 300.0, 200.0),
4355 );
4356 assert!(
4357 !tiny_state
4358 .scroll
4359 .thumb_rects
4360 .contains_key(&tiny.computed_id)
4361 );
4362 }
4363
4364 #[test]
4365 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
4366 let make_root = || {
4367 scroll([
4368 crate::tree::spacer().height(Size::Fixed(80.0)),
4369 scroll((0..6).map(|i| {
4370 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
4371 }))
4372 .key("inner")
4373 .height(Size::Fixed(120.0)),
4374 crate::tree::spacer().height(Size::Fixed(260.0)),
4375 ])
4376 .key("outer")
4377 .height(Size::Fixed(220.0))
4378 };
4379
4380 let mut root = make_root();
4381 let mut state = UiState::new();
4382 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4383 let inner = root
4384 .children
4385 .iter()
4386 .find(|child| child.key.as_deref() == Some("inner"))
4387 .expect("inner scroll");
4388 let inner_id = inner.computed_id.clone();
4389 let inner_rect = state.rect(&inner_id);
4390 let thumb = state
4391 .scroll
4392 .thumb_rects
4393 .get(&inner_id)
4394 .copied()
4395 .expect("inner scroll should have a thumb");
4396 let track = state
4397 .scroll
4398 .thumb_tracks
4399 .get(&inner_id)
4400 .copied()
4401 .expect("inner scroll should have a track");
4402 let thumb_rel_y = thumb.y - inner_rect.y;
4403 let track_rel_y = track.y - inner_rect.y;
4404
4405 state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
4406 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4407 let inner_rect_after = state.rect(&inner_id);
4408 let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
4409 let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
4410
4411 assert!(
4412 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
4413 "outer scroll should shift the inner viewport"
4414 );
4415 assert!(
4416 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
4417 "inner thumb should stay fixed relative to its viewport"
4418 );
4419 assert!(
4420 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
4421 "inner track should stay fixed relative to its viewport"
4422 );
4423 }
4424
4425 #[test]
4426 fn layout_override_places_children_at_returned_rects() {
4427 let mut root = column((0..3).map(|i| {
4429 crate::widgets::text::text(format!("dot {i}"))
4430 .width(Size::Fixed(20.0))
4431 .height(Size::Fixed(20.0))
4432 }))
4433 .width(Size::Fixed(200.0))
4434 .height(Size::Fixed(200.0))
4435 .layout(|ctx| {
4436 ctx.children
4437 .iter()
4438 .enumerate()
4439 .map(|(i, _)| {
4440 let off = i as f32 * 30.0;
4441 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
4442 })
4443 .collect()
4444 });
4445 let mut state = UiState::new();
4446 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4447 let r0 = state.rect(&root.children[0].computed_id);
4448 let r1 = state.rect(&root.children[1].computed_id);
4449 let r2 = state.rect(&root.children[2].computed_id);
4450 assert_eq!((r0.x, r0.y), (0.0, 0.0));
4451 assert_eq!((r1.x, r1.y), (30.0, 30.0));
4452 assert_eq!((r2.x, r2.y), (60.0, 60.0));
4453 }
4454
4455 #[test]
4456 fn layout_override_rect_of_key_resolves_earlier_sibling() {
4457 use crate::tree::stack;
4463 let trigger_x = 40.0;
4464 let trigger_y = 20.0;
4465 let trigger_w = 60.0;
4466 let trigger_h = 30.0;
4467 let mut root = stack([
4468 crate::widgets::button::button("Open")
4470 .key("trig")
4471 .width(Size::Fixed(trigger_w))
4472 .height(Size::Fixed(trigger_h)),
4473 stack([crate::widgets::text::text("popover")
4476 .width(Size::Fixed(80.0))
4477 .height(Size::Fixed(20.0))])
4478 .width(Size::Fill(1.0))
4479 .height(Size::Fill(1.0))
4480 .layout(|ctx| {
4481 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
4482 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
4483 }),
4484 ])
4485 .padding(Sides::xy(trigger_x, trigger_y));
4486 let mut state = UiState::new();
4487 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4488
4489 let popover_layer = &root.children[1];
4490 let panel_id = &popover_layer.children[0].computed_id;
4491 let panel_rect = state.rect(panel_id);
4492 assert!(
4495 (panel_rect.x - trigger_x).abs() < 0.01,
4496 "popover x = {} (expected {trigger_x})",
4497 panel_rect.x,
4498 );
4499 assert!(
4500 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
4501 "popover y = {} (expected {})",
4502 panel_rect.y,
4503 trigger_y + trigger_h + 4.0,
4504 );
4505 }
4506
4507 #[test]
4508 fn layout_override_rect_of_key_returns_none_for_missing_key() {
4509 let mut root = column([crate::widgets::text::text("inner")
4510 .width(Size::Fixed(40.0))
4511 .height(Size::Fixed(20.0))])
4512 .width(Size::Fixed(200.0))
4513 .height(Size::Fixed(200.0))
4514 .layout(|ctx| {
4515 assert!((ctx.rect_of_key)("nope").is_none());
4516 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
4517 });
4518 let mut state = UiState::new();
4519 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4520 }
4521
4522 #[test]
4523 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
4524 use crate::tree::stack;
4530 let mut root = stack([
4531 stack([crate::widgets::text::text("panel")
4532 .width(Size::Fixed(40.0))
4533 .height(Size::Fixed(20.0))])
4534 .width(Size::Fill(1.0))
4535 .height(Size::Fill(1.0))
4536 .layout(|ctx| {
4537 assert!(
4538 (ctx.rect_of_key)("later").is_none(),
4539 "later sibling's rect must not be available yet"
4540 );
4541 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
4542 }),
4543 crate::widgets::button::button("after").key("later"),
4544 ]);
4545 let mut state = UiState::new();
4546 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4547 }
4548
4549 #[test]
4550 fn layout_override_measure_returns_intrinsic() {
4551 let mut root = column([crate::widgets::text::text("hi")
4553 .width(Size::Fixed(40.0))
4554 .height(Size::Fixed(20.0))])
4555 .width(Size::Fixed(200.0))
4556 .height(Size::Fixed(200.0))
4557 .layout(|ctx| {
4558 let (w, h) = (ctx.measure)(&ctx.children[0]);
4559 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
4560 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
4561 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
4562 });
4563 let mut state = UiState::new();
4564 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4565 let r = state.rect(&root.children[0].computed_id);
4566 assert_eq!((r.w, r.h), (40.0, 20.0));
4567 }
4568
4569 #[test]
4570 #[should_panic(expected = "returned 1 rects for 2 children")]
4571 fn layout_override_length_mismatch_panics() {
4572 let mut root = column([
4573 crate::widgets::text::text("a")
4574 .width(Size::Fixed(10.0))
4575 .height(Size::Fixed(10.0)),
4576 crate::widgets::text::text("b")
4577 .width(Size::Fixed(10.0))
4578 .height(Size::Fixed(10.0)),
4579 ])
4580 .width(Size::Fixed(200.0))
4581 .height(Size::Fixed(200.0))
4582 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
4583 let mut state = UiState::new();
4584 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4585 }
4586
4587 #[test]
4588 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
4589 fn layout_override_hug_panics() {
4590 let mut root = column([column([crate::widgets::text::text("c")])
4594 .width(Size::Hug)
4595 .height(Size::Fixed(200.0))
4596 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
4597 .width(Size::Fixed(200.0))
4598 .height(Size::Fixed(200.0));
4599 let mut state = UiState::new();
4600 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4601 }
4602
4603 #[test]
4604 fn fit_contain_letterboxes_and_centers() {
4605 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted");
4608 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
4609 .width(Size::Fixed(400.0))
4610 .height(Size::Fixed(400.0));
4611 let mut state = UiState::new();
4612 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4613
4614 let r = state.rect_of_key("fitted").expect("fitted rect");
4615 assert_eq!((r.w, r.h), (400.0, 225.0));
4616 assert_eq!(r.x, 0.0);
4617 assert!((r.y - 87.5).abs() < 0.01, "centered: y = {}", r.y);
4618
4619 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted2");
4621 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
4622 .width(Size::Fixed(400.0))
4623 .height(Size::Fixed(100.0));
4624 let mut state = UiState::new();
4625 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4626 let r = state.rect_of_key("fitted2").expect("fitted2 rect");
4627 assert!((r.w - 100.0 * 16.0 / 9.0).abs() < 0.01);
4628 assert_eq!(r.h, 100.0);
4629 assert!((r.x - (400.0 - r.w) / 2.0).abs() < 0.01);
4630 }
4631
4632 #[test]
4633 fn fit_cover_overflows_the_slack_axis_and_clips() {
4634 let child = crate::tree::column([crate::widgets::text::text("c")]).key("covered");
4638 let mut root = crate::tree::fit_cover(child, 1.0)
4639 .width(Size::Fixed(400.0))
4640 .height(Size::Fixed(200.0));
4641 assert!(root.clip, "fit_cover must clip its overflow");
4642 let mut state = UiState::new();
4643 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
4644 let r = state.rect_of_key("covered").expect("covered rect");
4645 assert_eq!((r.w, r.h), (400.0, 400.0));
4646 assert!(
4647 (r.y - -100.0).abs() < 0.01,
4648 "centered overflow: y = {}",
4649 r.y
4650 );
4651 }
4652
4653 #[test]
4654 fn fit_contain_intrinsic_uses_the_child_measure() {
4655 let child = crate::tree::column::<Vec<El>, El>(vec![])
4657 .width(Size::Fixed(100.0))
4658 .height(Size::Fixed(50.0))
4659 .key("intrinsic");
4660 let mut root = crate::tree::fit_contain_intrinsic(child)
4661 .width(Size::Fixed(400.0))
4662 .height(Size::Fixed(400.0));
4663 let mut state = UiState::new();
4664 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4665 let r = state.rect_of_key("intrinsic").expect("intrinsic rect");
4666 assert_eq!((r.w, r.h), (400.0, 200.0));
4667 }
4668
4669 #[test]
4670 fn virtual_list_realizes_only_visible_rows() {
4671 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
4675 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4676 });
4677 let mut state = UiState::new();
4678 assign_ids(&mut root);
4679 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
4680 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4681
4682 assert_eq!(
4683 root.children.len(),
4684 5,
4685 "expected 5 realized rows, got {}",
4686 root.children.len()
4687 );
4688 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
4690 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
4691 let r0 = state.rect(&root.children[0].computed_id);
4693 assert!(
4694 (r0.y - (-20.0)).abs() < 0.5,
4695 "row 2 expected y≈-20, got {}",
4696 r0.y
4697 );
4698 }
4699
4700 #[test]
4701 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
4702 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
4703 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4704 })
4705 .gap(10.0);
4706 let mut state = UiState::new();
4707 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
4708
4709 assert_eq!(
4710 root.children.len(),
4711 3,
4712 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
4713 );
4714 let row_1 = root
4715 .children
4716 .iter()
4717 .find(|c| c.key.as_deref() == Some("row-1"))
4718 .expect("row 1 should be realized");
4719 assert!(
4720 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
4721 "gap should place row 1 at y=50"
4722 );
4723 let metrics = state
4724 .scroll
4725 .metrics
4726 .get(&root.computed_id)
4727 .expect("virtual list writes scroll metrics");
4728 assert!(
4729 (metrics.content_h - 490.0).abs() < 0.5,
4730 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
4731 metrics.content_h
4732 );
4733 }
4734
4735 #[test]
4736 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
4737 let make_root = || {
4738 crate::tree::virtual_list(50, 50.0, |i| {
4739 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4740 })
4741 };
4742
4743 let mut state = UiState::new();
4744 let mut root_a = make_root();
4745 assign_ids(&mut root_a);
4746 state
4748 .scroll
4749 .offsets
4750 .insert(root_a.computed_id.clone(), 250.0);
4751 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4752 let id_at_offset_a = root_a
4753 .children
4754 .iter()
4755 .find(|c| c.key.as_deref() == Some("row-5"))
4756 .unwrap()
4757 .computed_id
4758 .clone();
4759
4760 let mut root_b = make_root();
4762 assign_ids(&mut root_b);
4763 state
4764 .scroll
4765 .offsets
4766 .insert(root_b.computed_id.clone(), 200.0);
4767 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4768 let id_at_offset_b = root_b
4769 .children
4770 .iter()
4771 .find(|c| c.key.as_deref() == Some("row-5"))
4772 .unwrap()
4773 .computed_id
4774 .clone();
4775
4776 assert_eq!(
4777 id_at_offset_a, id_at_offset_b,
4778 "row-5's computed_id changed when scroll offset moved"
4779 );
4780 }
4781
4782 #[test]
4783 fn virtual_list_clamps_overshoot_offset() {
4784 let mut root =
4786 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
4787 let mut state = UiState::new();
4788 assign_ids(&mut root);
4789 state
4790 .scroll
4791 .offsets
4792 .insert(root.computed_id.clone(), 9999.0);
4793 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4794 let stored = state
4795 .scroll
4796 .offsets
4797 .get(&root.computed_id)
4798 .copied()
4799 .unwrap_or(0.0);
4800 assert!(
4801 (stored - 300.0).abs() < 0.01,
4802 "expected clamp to 300, got {stored}"
4803 );
4804 }
4805
4806 #[test]
4807 fn virtual_list_empty_count_realizes_no_children() {
4808 let mut root =
4809 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
4810 let mut state = UiState::new();
4811 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4812 assert_eq!(root.children.len(), 0);
4813 }
4814
4815 #[test]
4816 #[should_panic(expected = "row_height > 0.0")]
4817 fn virtual_list_zero_row_height_panics() {
4818 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
4819 }
4820
4821 #[test]
4822 #[should_panic(expected = "Size::Hug would defeat virtualization")]
4823 fn virtual_list_hug_panics() {
4824 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
4825 crate::widgets::text::text(format!("r{i}"))
4826 })
4827 .height(Size::Hug)])
4828 .width(Size::Fixed(300.0))
4829 .height(Size::Fixed(200.0));
4830 let mut state = UiState::new();
4831 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4832 }
4833
4834 #[test]
4835 fn grid_packs_rows_and_aligns_the_partial_tail() {
4836 let cell = |i: usize| {
4840 crate::tree::column::<Vec<El>, El>(vec![])
4841 .key(format!("cell-{i}"))
4842 .height(Size::Fixed(50.0))
4843 };
4844 let mut root = crate::tree::grid(2, 10.0, (0..3).map(cell))
4845 .width(Size::Fixed(210.0))
4846 .height(Size::Fixed(300.0));
4847 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
4848 let mut state = UiState::new();
4849 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 210.0, 300.0));
4850
4851 let r0 = state.rect_of_key("cell-0").unwrap();
4852 let r1 = state.rect_of_key("cell-1").unwrap();
4853 let r2 = state.rect_of_key("cell-2").unwrap();
4854 assert_eq!((r0.x, r0.w), (0.0, 100.0));
4855 assert_eq!((r1.x, r1.w), (110.0, 100.0));
4856 assert_eq!((r2.x, r2.w), (0.0, 100.0));
4858 assert_eq!(r2.y, 60.0);
4859 }
4860
4861 #[test]
4862 fn virtual_grid_realizes_rows_of_packed_cells() {
4863 let mut root = crate::tree::virtual_grid(10, 3, 50.0, 0.0, |i| {
4866 crate::widgets::text::text(format!("item {i}")).key(format!("item-{i}"))
4867 })
4868 .key("vgrid");
4869 let mut state = UiState::new();
4870 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
4871
4872 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
4873 let rect_of = |state: &UiState, row: usize, col: usize| {
4878 state.rect(&root.children[row].children[col].computed_id)
4879 };
4880 let i0 = rect_of(&state, 0, 0);
4882 let i2 = rect_of(&state, 0, 2);
4883 assert_eq!(i0.w, 100.0);
4884 assert_eq!(i2.x, 200.0);
4885 let i3 = rect_of(&state, 1, 0);
4887 assert_eq!((i3.x, i3.y), (0.0, 50.0));
4888 assert!(state.visible_range("vgrid").is_some());
4890 assert_eq!(root.children[0].children[0].key.as_deref(), Some("item-0"));
4892 }
4893
4894 #[test]
4895 fn visible_range_tracks_realized_rows() {
4896 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
4900 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
4901 })
4902 .key("list");
4903 let mut state = UiState::new();
4904 assign_ids(&mut root);
4905 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
4906 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4907
4908 assert_eq!(state.visible_range("list"), Some(2..7));
4909 assert_eq!(state.visible_range("not-a-list"), None);
4910
4911 let mut dyn_root = crate::tree::virtual_list_dyn(
4914 20,
4915 50.0,
4916 |i| format!("row-{i}"),
4917 |i| {
4918 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
4919 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4920 .key(format!("row-{i}"))
4921 .height(Size::Fixed(h))
4922 },
4923 )
4924 .key("dyn-list");
4925 let mut dyn_state = UiState::new();
4926 layout(
4927 &mut dyn_root,
4928 &mut dyn_state,
4929 Rect::new(0.0, 0.0, 300.0, 200.0),
4930 );
4931 assert_eq!(dyn_state.visible_range("dyn-list"), Some(0..4));
4932
4933 let mut empty =
4935 crate::tree::virtual_list(0, 50.0, |_| crate::widgets::text::text("never")).key("list");
4936 layout(&mut empty, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4937 assert_eq!(state.visible_range("list"), None);
4938 }
4939
4940 #[test]
4941 fn virtual_list_dyn_respects_per_row_fixed_heights() {
4942 let mut root = crate::tree::virtual_list_dyn(
4946 20,
4947 50.0,
4948 |i| format!("row-{i}"),
4949 |i| {
4950 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
4951 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
4952 .key(format!("row-{i}"))
4953 .height(Size::Fixed(h))
4954 },
4955 );
4956 let mut state = UiState::new();
4957 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4958
4959 assert_eq!(
4960 root.children.len(),
4961 4,
4962 "expected 4 realized rows, got {}",
4963 root.children.len()
4964 );
4965 let ys: Vec<f32> = root
4967 .children
4968 .iter()
4969 .map(|c| state.rect(&c.computed_id).y)
4970 .collect();
4971 assert!(
4972 (ys[0] - 0.0).abs() < 0.5,
4973 "row 0 expected y≈0, got {}",
4974 ys[0]
4975 );
4976 assert!(
4977 (ys[1] - 40.0).abs() < 0.5,
4978 "row 1 expected y≈40, got {}",
4979 ys[1]
4980 );
4981 assert!(
4982 (ys[2] - 120.0).abs() < 0.5,
4983 "row 2 expected y≈120, got {}",
4984 ys[2]
4985 );
4986 assert!(
4987 (ys[3] - 160.0).abs() < 0.5,
4988 "row 3 expected y≈160, got {}",
4989 ys[3]
4990 );
4991 }
4992
4993 #[test]
4994 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
4995 let mut root = crate::tree::virtual_list_dyn(
4996 10,
4997 40.0,
4998 |i| format!("row-{i}"),
4999 |i| {
5000 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
5001 .key(format!("row-{i}"))
5002 .height(Size::Fixed(40.0))
5003 },
5004 )
5005 .gap(10.0);
5006 let mut state = UiState::new();
5007 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5008
5009 assert_eq!(
5010 root.children.len(),
5011 3,
5012 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5013 );
5014 let row_1 = root
5015 .children
5016 .iter()
5017 .find(|c| c.key.as_deref() == Some("row-1"))
5018 .expect("row 1 should be realized");
5019 assert!(
5020 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
5021 "gap should place row 1 at y=50"
5022 );
5023 let metrics = state
5024 .scroll
5025 .metrics
5026 .get(&root.computed_id)
5027 .expect("virtual list writes scroll metrics");
5028 assert!(
5029 (metrics.content_h - 490.0).abs() < 0.5,
5030 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5031 metrics.content_h
5032 );
5033 }
5034
5035 #[test]
5036 fn virtual_list_dyn_caches_measured_heights() {
5037 let mut root = crate::tree::virtual_list_dyn(
5041 50,
5042 50.0,
5043 |i| format!("row-{i}"),
5044 |i| {
5045 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5046 .key(format!("row-{i}"))
5047 .height(Size::Fixed(30.0))
5048 },
5049 );
5050 let mut state = UiState::new();
5051 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5052
5053 let measured = state
5054 .scroll
5055 .measured_row_heights
5056 .get(&root.computed_id)
5057 .expect("dynamic virtual list should populate the height cache");
5058 assert!(
5062 measured.len() >= 6,
5063 "expected ≥ 6 cached row heights, got {}",
5064 measured.len()
5065 );
5066 for by_width in measured.values() {
5067 let h = by_width
5068 .get(&300)
5069 .copied()
5070 .expect("measurement should be keyed at the 300px width bucket");
5071 assert!(
5072 (h - 30.0).abs() < 0.5,
5073 "expected cached height ≈ 30, got {h}"
5074 );
5075 }
5076 }
5077
5078 #[test]
5079 fn virtual_list_dyn_measure_pass_seeds_intrinsic_cache_for_layout_pass() {
5080 let mut root = crate::tree::virtual_list_dyn(
5086 50,
5087 20.0,
5088 |i| format!("row-{i}"),
5089 |i| {
5090 crate::tree::column([crate::widgets::text::text(format!("row body {i}"))])
5091 .key(format!("row-{i}"))
5092 .height(Size::Hug)
5093 },
5094 );
5095 let mut state = UiState::new();
5096 let _ = take_intrinsic_cache_stats();
5097 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5098 let stats = take_intrinsic_cache_stats();
5099 let realized = root.children.len() as u64;
5100 assert!(realized > 0, "test requires realized rows");
5101 assert!(
5102 stats.hits >= realized,
5103 "layout pass should re-measure realized rows from the intrinsic cache \
5104 (hits {} < realized {realized})",
5105 stats.hits
5106 );
5107 }
5108
5109 #[test]
5110 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
5111 let make_root = || {
5112 crate::tree::virtual_list_dyn(
5113 100,
5114 40.0,
5115 |i| format!("row-{i}"),
5116 |i| {
5117 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5118 .key(format!("row-{i}"))
5119 .height(Size::Fixed(40.0))
5120 },
5121 )
5122 };
5123 let mut root = make_root();
5124 let mut state = UiState::new();
5125 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5126
5127 state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
5128 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5129
5130 let anchor = state
5131 .scroll
5132 .virtual_anchors
5133 .get(&root.computed_id)
5134 .cloned()
5135 .expect("dynamic list should store a visible anchor");
5136 let before_y = root
5137 .children
5138 .iter()
5139 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5140 .map(|child| state.rect(&child.computed_id).y)
5141 .expect("anchor row should be realized");
5142 let before_offset = state.scroll_offset(&root.computed_id);
5143
5144 state
5145 .scroll
5146 .measured_row_heights
5147 .entry(root.computed_id.clone())
5148 .or_default()
5149 .entry("row-0".to_string())
5150 .or_default()
5151 .insert(300, 120.0);
5152
5153 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5154 let after_y = root
5155 .children
5156 .iter()
5157 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5158 .map(|child| state.rect(&child.computed_id).y)
5159 .expect("anchor row should remain realized");
5160 let after_offset = state.scroll_offset(&root.computed_id);
5161
5162 assert!(
5163 (after_y - before_y).abs() < 0.5,
5164 "anchor row should stay at y={before_y}, got {after_y}"
5165 );
5166 assert!(
5167 (after_offset - (before_offset + 80.0)).abs() < 0.5,
5168 "offset should absorb the 80px measurement delta above anchor"
5169 );
5170 }
5171
5172 #[test]
5173 fn virtual_list_dyn_height_cache_is_width_bucketed() {
5174 let mut root = crate::tree::virtual_list_dyn(
5175 20,
5176 50.0,
5177 |i| format!("row-{i}"),
5178 |i| {
5179 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5180 .key(format!("row-{i}"))
5181 .height(Size::Fixed(30.0))
5182 },
5183 );
5184 let mut state = UiState::new();
5185 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5186 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
5187
5188 let row_0 = state
5189 .scroll
5190 .measured_row_heights
5191 .get(&root.computed_id)
5192 .and_then(|m| m.get("row-0"))
5193 .expect("row 0 should be measured");
5194 assert!(
5195 row_0.contains_key(&300) && row_0.contains_key(&240),
5196 "expected width buckets 300 and 240, got {:?}",
5197 row_0.keys().collect::<Vec<_>>()
5198 );
5199 }
5200
5201 #[test]
5202 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
5203 let make_root = || {
5208 crate::tree::virtual_list_dyn(
5209 20,
5210 50.0,
5211 |i| format!("row-{i}"),
5212 |i| {
5213 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5214 .key(format!("row-{i}"))
5215 .height(Size::Fixed(30.0))
5216 },
5217 )
5218 };
5219 let mut state = UiState::new();
5220 let mut root = make_root();
5221 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5222
5223 state
5224 .scroll
5225 .offsets
5226 .insert(root.computed_id.clone(), 9999.0);
5227 let mut root2 = make_root();
5228 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5229
5230 let measured = state
5231 .scroll
5232 .measured_row_heights
5233 .get(&root2.computed_id)
5234 .expect("dynamic virtual list should populate the height cache");
5235 let measured_sum = measured
5236 .values()
5237 .filter_map(|by_width| by_width.get(&300))
5238 .sum::<f32>();
5239 let measured_count = measured
5240 .values()
5241 .filter(|by_width| by_width.contains_key(&300))
5242 .count();
5243 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
5244 let expected_max_offset = expected_total - 200.0;
5245
5246 let stored = state
5247 .scroll
5248 .offsets
5249 .get(&root2.computed_id)
5250 .copied()
5251 .unwrap_or(0.0);
5252 assert!(
5253 (stored - expected_max_offset).abs() < 0.5,
5254 "expected offset clamped to {expected_max_offset}, got {stored}"
5255 );
5256 }
5257
5258 fn parity_list(first: usize, count: usize, append_only: bool, pin: bool) -> El {
5272 let mut el = crate::tree::virtual_list_dyn(
5273 count,
5274 20.0,
5275 move |i| format!("m{}", first + i),
5276 move |i| {
5277 let id = first + i;
5278 let h = 30.0 + (id % 7) as f32 * 10.0;
5279 crate::tree::column([crate::widgets::text::text(format!("m{id}"))])
5280 .key(format!("m{id}"))
5281 .height(Size::Fixed(h))
5282 },
5283 )
5284 .key("chat")
5285 .gap(4.0);
5286 if pin {
5287 el = el.pin_end();
5288 }
5289 if append_only {
5290 el = el.append_only();
5291 }
5292 el
5293 }
5294
5295 fn parity_rows(root: &El, state: &UiState) -> Vec<(String, Rect)> {
5297 root.children
5298 .iter()
5299 .map(|c| {
5300 (
5301 c.key.clone().unwrap_or_default(),
5302 state.rect(&c.computed_id),
5303 )
5304 })
5305 .collect()
5306 }
5307
5308 struct Frame {
5309 first: usize,
5310 count: usize,
5311 seed_offset: Option<f32>,
5312 request: Option<ScrollRequest>,
5313 }
5314
5315 fn run_parity(frames: &[Frame], pin: bool) {
5316 let mut sg = UiState::new(); let mut si = UiState::new(); for (n, f) in frames.iter().enumerate() {
5320 let viewport = Rect::new(0.0, 0.0, 300.0, 200.0);
5321 let step = |state: &mut UiState, append_only: bool| {
5322 let mut root = parity_list(f.first, f.count, append_only, pin);
5323 assign_ids(&mut root);
5324 if let Some(o) = f.seed_offset {
5325 state.scroll.offsets.insert(root.computed_id.clone(), o);
5326 }
5327 if let Some(req) = &f.request {
5328 state.push_scroll_requests(vec![req.clone()]);
5329 }
5330 layout(&mut root, state, viewport);
5331 root
5332 };
5333
5334 let root_g = step(&mut sg, false);
5335 let root_i = step(&mut si, true);
5336
5337 let rows_g = parity_rows(&root_g, &sg);
5338 let rows_i = parity_rows(&root_i, &si);
5339 assert_eq!(
5340 rows_g.len(),
5341 rows_i.len(),
5342 "frame {n}: realized row count differs (general {} vs incremental {})",
5343 rows_g.len(),
5344 rows_i.len()
5345 );
5346 for ((kg, rg), (ki, ri)) in rows_g.iter().zip(&rows_i) {
5347 assert_eq!(kg, ki, "frame {n}: realized key order differs");
5348 assert!(
5349 (rg.x - ri.x).abs() < 1e-2
5350 && (rg.y - ri.y).abs() < 1e-2
5351 && (rg.w - ri.w).abs() < 1e-2
5352 && (rg.h - ri.h).abs() < 1e-2,
5353 "frame {n}: rect for {kg} differs: general {rg:?} vs incremental {ri:?}"
5354 );
5355 }
5356
5357 let off_g = sg.scroll.offsets.get(&root_g.computed_id).copied();
5358 let off_i = si.scroll.offsets.get(&root_i.computed_id).copied();
5359 assert!(
5360 (off_g.unwrap_or(0.0) - off_i.unwrap_or(0.0)).abs() < 1e-2,
5361 "frame {n}: stored offset differs: general {off_g:?} vs incremental {off_i:?}"
5362 );
5363 assert_eq!(
5364 sg.visible_range("chat"),
5365 si.visible_range("chat"),
5366 "frame {n}: visible range differs"
5367 );
5368 }
5369 }
5370
5371 #[test]
5372 fn append_only_matches_general_top_append_scroll_trim() {
5373 run_parity(
5374 &[
5375 Frame {
5377 first: 0,
5378 count: 30,
5379 seed_offset: Some(0.0),
5380 request: None,
5381 },
5382 Frame {
5384 first: 0,
5385 count: 42,
5386 seed_offset: None,
5387 request: None,
5388 },
5389 Frame {
5391 first: 0,
5392 count: 42,
5393 seed_offset: Some(400.0),
5394 request: None,
5395 },
5396 Frame {
5398 first: 8,
5399 count: 50,
5400 seed_offset: None,
5401 request: None,
5402 },
5403 Frame {
5405 first: 8,
5406 count: 62,
5407 seed_offset: None,
5408 request: None,
5409 },
5410 Frame {
5412 first: 20,
5413 count: 62,
5414 seed_offset: None,
5415 request: None,
5416 },
5417 ],
5418 false,
5419 );
5420 }
5421
5422 #[test]
5423 fn append_only_matches_general_to_row_key_request() {
5424 run_parity(
5425 &[
5426 Frame {
5427 first: 0,
5428 count: 40,
5429 seed_offset: Some(0.0),
5430 request: None,
5431 },
5432 Frame {
5434 first: 0,
5435 count: 40,
5436 seed_offset: None,
5437 request: Some(ScrollRequest::ToRowKey {
5438 list_key: "chat".into(),
5439 row_key: "m30".into(),
5440 align: ScrollAlignment::Start,
5441 }),
5442 },
5443 Frame {
5445 first: 12,
5446 count: 55,
5447 seed_offset: None,
5448 request: None,
5449 },
5450 ],
5451 false,
5452 );
5453 }
5454
5455 #[test]
5461 fn append_only_matches_general_across_width_change() {
5462 let mut sg = UiState::new();
5463 let mut si = UiState::new();
5464 let script = [
5466 (0usize, 30usize, 300.0f32),
5467 (0, 40, 300.0),
5468 (0, 40, 240.0), (6, 52, 240.0), (6, 52, 360.0), ];
5472 for (n, &(first, count, w)) in script.iter().enumerate() {
5473 let viewport = Rect::new(0.0, 0.0, w, 200.0);
5474 let step = |state: &mut UiState, append_only: bool| {
5475 let mut root = parity_list(first, count, append_only, false);
5476 assign_ids(&mut root);
5477 layout(&mut root, state, viewport);
5478 root
5479 };
5480 let rg = step(&mut sg, false);
5481 let ri = step(&mut si, true);
5482 let rows_g = parity_rows(&rg, &sg);
5483 let rows_i = parity_rows(&ri, &si);
5484 assert_eq!(rows_g.len(), rows_i.len(), "frame {n}: row count differs");
5485 for ((kg, a), (ki, b)) in rows_g.iter().zip(&rows_i) {
5486 assert_eq!(kg, ki, "frame {n}: key order differs");
5487 assert!(
5488 (a.x - b.x).abs() < 1e-2
5489 && (a.y - b.y).abs() < 1e-2
5490 && (a.w - b.w).abs() < 1e-2
5491 && (a.h - b.h).abs() < 1e-2,
5492 "frame {n}: rect for {kg} differs: {a:?} vs {b:?}"
5493 );
5494 }
5495 assert_eq!(
5496 sg.visible_range("chat"),
5497 si.visible_range("chat"),
5498 "frame {n}: visible range differs"
5499 );
5500 }
5501 }
5502
5503 #[test]
5504 fn append_only_matches_general_pin_end_stick_to_bottom() {
5505 run_parity(
5506 &[
5507 Frame {
5508 first: 0,
5509 count: 20,
5510 seed_offset: None,
5511 request: None,
5512 },
5513 Frame {
5515 first: 0,
5516 count: 35,
5517 seed_offset: None,
5518 request: None,
5519 },
5520 Frame {
5522 first: 10,
5523 count: 50,
5524 seed_offset: None,
5525 request: None,
5526 },
5527 Frame {
5528 first: 25,
5529 count: 70,
5530 seed_offset: None,
5531 request: None,
5532 },
5533 ],
5534 true,
5535 );
5536 }
5537
5538 #[test]
5539 fn virtual_list_dyn_empty_count_realizes_no_children() {
5540 let mut root = crate::tree::virtual_list_dyn(
5541 0,
5542 50.0,
5543 |i| format!("row-{i}"),
5544 |i| crate::widgets::text::text(format!("r{i}")),
5545 );
5546 let mut state = UiState::new();
5547 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5548 assert_eq!(root.children.len(), 0);
5549 }
5550
5551 #[test]
5552 #[should_panic(expected = "estimated_row_height > 0.0")]
5553 fn virtual_list_dyn_zero_estimate_panics() {
5554 let _ = crate::tree::virtual_list_dyn(
5555 10,
5556 0.0,
5557 |i| format!("row-{i}"),
5558 |i| crate::widgets::text::text(format!("r{i}")),
5559 );
5560 }
5561
5562 #[test]
5563 fn text_runs_constructor_shape_smoke() {
5564 let el = crate::tree::text_runs([
5565 crate::widgets::text::text("Hello, "),
5566 crate::widgets::text::text("world").bold(),
5567 crate::tree::hard_break(),
5568 crate::widgets::text::text("of text").italic(),
5569 ]);
5570 assert_eq!(el.kind, Kind::Inlines);
5571 assert_eq!(el.children.len(), 4);
5572 assert!(matches!(
5573 el.children[1].font_weight,
5574 FontWeight::Bold | FontWeight::Semibold
5575 ));
5576 assert_eq!(el.children[2].kind, Kind::HardBreak);
5577 assert!(el.children[3].text_italic);
5578 }
5579
5580 #[test]
5581 fn wrapped_text_hugs_multiline_height_from_available_width() {
5582 let mut root = column([crate::paragraph(
5583 "A longer sentence should wrap into multiple measured lines.",
5584 )])
5585 .width(Size::Fill(1.0))
5586 .height(Size::Hug);
5587
5588 let mut state = UiState::new();
5589 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
5590
5591 let child_rect = state.rect(&root.children[0].computed_id);
5592 assert_eq!(child_rect.w, 180.0);
5593 assert!(
5594 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
5595 "expected multiline paragraph height, got {}",
5596 child_rect.h
5597 );
5598 }
5599
5600 #[test]
5601 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
5602 const PANEL_W: f32 = 240.0;
5613 const PADDING: f32 = 18.0;
5614 const GAP: f32 = 12.0;
5615
5616 let panel = column([
5617 crate::paragraph(
5618 "A long enough warning paragraph that it has to wrap onto a second line \
5619 inside this narrow panel.",
5620 ),
5621 crate::widgets::button::button("OK").key("ok"),
5622 ])
5623 .width(Size::Fixed(PANEL_W))
5624 .height(Size::Hug)
5625 .padding(Sides::all(PADDING))
5626 .gap(GAP)
5627 .align(Align::Stretch);
5628
5629 let mut root = crate::stack([panel])
5630 .width(Size::Fill(1.0))
5631 .height(Size::Fill(1.0));
5632 let mut state = UiState::new();
5633 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
5634
5635 let panel_rect = state.rect(&root.children[0].computed_id);
5636 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
5637
5638 let para_rect = state.rect(&root.children[0].children[0].computed_id);
5639 let button_rect = state.rect(&root.children[0].children[1].computed_id);
5640
5641 assert!(
5644 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
5645 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
5646 got h={}",
5647 para_rect.h
5648 );
5649
5650 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
5656 assert!(
5657 (bottom_padding - PADDING).abs() < 0.5,
5658 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
5659 );
5660 }
5661
5662 #[test]
5663 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
5664 const COL_W: f32 = 600.0;
5676 const GUTTER_W: f32 = 3.0;
5677
5678 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
5679 sed do eiusmod tempor incididunt ut labore et dolore magna \
5680 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
5681 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
5682
5683 let make_row = || {
5684 let gutter = El::new(Kind::Custom("gutter"))
5685 .width(Size::Fixed(GUTTER_W))
5686 .height(Size::Fill(1.0));
5687 let body = crate::paragraph(long).width(Size::Fill(1.0));
5688 crate::row([gutter, body]).width(Size::Fill(1.0))
5689 };
5690
5691 let mut root = column([make_row(), make_row()])
5692 .width(Size::Fixed(COL_W))
5693 .height(Size::Hug)
5694 .align(Align::Stretch);
5695 let mut state = UiState::new();
5696 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
5697
5698 let row0_rect = state.rect(&root.children[0].computed_id);
5699 let row1_rect = state.rect(&root.children[1].computed_id);
5700 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
5701
5702 let line_height = crate::tokens::TEXT_SM.line_height;
5707 assert!(
5708 para0_rect.h > line_height * 1.5,
5709 "paragraph should wrap to multiple lines at ~597px wide; \
5710 got h={} (line_height={})",
5711 para0_rect.h,
5712 line_height,
5713 );
5714 assert!(
5715 row0_rect.h > line_height * 1.5,
5716 "row 0 should accommodate the wrapped paragraph height; \
5717 got h={} (line_height={})",
5718 row0_rect.h,
5719 line_height,
5720 );
5721
5722 assert!(
5724 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
5725 "row 1 starts at y={} but row 0 occupies y={}..{}",
5726 row1_rect.y,
5727 row0_rect.y,
5728 row0_rect.y + row0_rect.h,
5729 );
5730 }
5731
5732 #[test]
5737 fn min_width_floors_resolved_cross_axis_size() {
5738 let mut root = column([crate::widgets::text::text("hi")
5739 .width(Size::Fixed(40.0))
5740 .height(Size::Fixed(20.0))
5741 .min_width(120.0)])
5742 .align(Align::Start)
5743 .width(Size::Fixed(500.0))
5744 .height(Size::Fixed(200.0));
5745 let mut state = UiState::new();
5746 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
5747 let child_rect = state.rect(&root.children[0].computed_id);
5748 assert!(
5749 (child_rect.w - 120.0).abs() < 0.5,
5750 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
5751 child_rect.w,
5752 );
5753 }
5754
5755 #[test]
5758 fn max_width_caps_fill_child() {
5759 let mut root = crate::row([crate::widgets::text::text("body")
5760 .width(Size::Fill(1.0))
5761 .height(Size::Fixed(20.0))
5762 .max_width(160.0)])
5763 .width(Size::Fixed(800.0))
5764 .height(Size::Fixed(40.0));
5765 let mut state = UiState::new();
5766 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
5767 let child_rect = state.rect(&root.children[0].computed_id);
5768 assert!(
5769 (child_rect.w - 160.0).abs() < 0.5,
5770 "expected Fill child capped at 160, got w={}",
5771 child_rect.w,
5772 );
5773 }
5774
5775 #[test]
5779 fn ch_unit_reserves_constant_digit_width() {
5780 use crate::widgets::text::text;
5781 let mut root = column([
5782 text("8")
5783 .tabular_numerals()
5784 .width(Size::Ch(4.0))
5785 .height(Size::Fixed(20.0)),
5786 text("123456")
5787 .tabular_numerals()
5788 .width(Size::Ch(4.0))
5789 .height(Size::Fixed(20.0)),
5790 text("8")
5791 .tabular_numerals()
5792 .width(Size::Ch(2.0))
5793 .height(Size::Fixed(20.0)),
5794 ]);
5795 let mut state = UiState::new();
5796 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
5797 let four_a = state.rect(&root.children[0].computed_id).w;
5798 let four_b = state.rect(&root.children[1].computed_id).w;
5799 let two = state.rect(&root.children[2].computed_id).w;
5800 assert!(four_a > 0.0);
5801 assert!(
5803 (four_a - four_b).abs() < 0.01,
5804 "Ch(4) width must not depend on the text: {four_a} vs {four_b}"
5805 );
5806 assert!(
5808 (four_a - 2.0 * two).abs() < 0.5,
5809 "Ch(4) should be twice Ch(2): {four_a} vs 2×{two}"
5810 );
5811 }
5812
5813 #[test]
5816 fn min_width_wins_over_max_width_when_conflicting() {
5817 let mut root = column([crate::widgets::text::text("x")
5818 .width(Size::Fixed(50.0))
5819 .height(Size::Fixed(20.0))
5820 .max_width(80.0)
5821 .min_width(120.0)]);
5822 let mut state = UiState::new();
5823 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
5824 let child_rect = state.rect(&root.children[0].computed_id);
5825 assert!(
5826 (child_rect.w - 120.0).abs() < 0.5,
5827 "expected min_width (120) to win over max_width (80), got w={}",
5828 child_rect.w,
5829 );
5830 }
5831
5832 #[test]
5836 fn min_height_floors_hug_column_inside_fixed_parent() {
5837 let inner = column([crate::widgets::text::text("a")
5838 .width(Size::Fixed(40.0))
5839 .height(Size::Fixed(20.0))])
5840 .width(Size::Fixed(80.0))
5841 .height(Size::Hug)
5842 .min_height(200.0);
5843 let mut root = column([inner])
5844 .align(Align::Start)
5845 .width(Size::Fixed(800.0))
5846 .height(Size::Fixed(600.0));
5847 let mut state = UiState::new();
5848 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
5849 let inner_rect = state.rect(&root.children[0].computed_id);
5850 assert!(
5851 (inner_rect.h - 200.0).abs() < 0.5,
5852 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
5853 inner_rect.h,
5854 );
5855 }
5856
5857 #[test]
5866 fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
5867 let mut root = crate::row([
5871 column([crate::widgets::text::paragraph(
5872 "A long enough description that must wrap to two lines at 148px",
5873 )])
5874 .width(Size::Fill(1.0)),
5875 crate::widgets::text::text("ok")
5876 .width(Size::Fixed(40.0))
5877 .height(Size::Fixed(20.0)),
5878 ])
5879 .gap(12.0)
5880 .align(Align::Center)
5881 .width(Size::Fixed(200.0));
5882 let mut state = UiState::new();
5883 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
5884 let col_rect = state.rect(&root.children[0].computed_id);
5886 let para_rect = state.rect(&root.children[0].children[0].computed_id);
5887 assert!(
5888 (col_rect.h - para_rect.h).abs() < 0.5,
5889 "column height ({}) should track its wrapped child's height ({})",
5890 col_rect.h,
5891 para_rect.h,
5892 );
5893 }
5894
5895 #[test]
5899 fn aspect_on_column_main_axis_derives_from_cross() {
5900 let mut root = column([El::new(Kind::Group)
5901 .width(Size::Fill(1.0))
5902 .height(Size::Aspect(0.5))])
5903 .width(Size::Fixed(200.0))
5904 .height(Size::Fixed(400.0));
5905 let mut state = UiState::new();
5906 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
5907 let r = state.rect(&root.children[0].computed_id);
5908 assert!(
5909 (r.w - 200.0).abs() < 0.5,
5910 "expected w≈200 (Fill), got {}",
5911 r.w,
5912 );
5913 assert!(
5914 (r.h - 100.0).abs() < 0.5,
5915 "expected h≈100 (Aspect 0.5 of 200), got {}",
5916 r.h,
5917 );
5918 }
5919
5920 #[test]
5924 fn aspect_height_pushes_siblings_in_column() {
5925 let mut root = column([
5926 El::new(Kind::Group)
5927 .width(Size::Fill(1.0))
5928 .height(Size::Aspect(0.25)),
5929 crate::widgets::text::text("caption")
5930 .width(Size::Fixed(40.0))
5931 .height(Size::Fixed(20.0)),
5932 ])
5933 .width(Size::Fixed(400.0))
5934 .height(Size::Fixed(500.0));
5935 let mut state = UiState::new();
5936 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
5937 let img = state.rect(&root.children[0].computed_id);
5938 let cap = state.rect(&root.children[1].computed_id);
5939 assert!(
5940 (img.h - 100.0).abs() < 0.5,
5941 "expected aspect-derived height ≈100, got {}",
5942 img.h,
5943 );
5944 assert!(
5945 (cap.y - 100.0).abs() < 0.5,
5946 "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
5947 cap.y,
5948 );
5949 }
5950
5951 #[test]
5955 fn aspect_on_row_cross_axis_derives_from_main() {
5956 let mut root = crate::row([El::new(Kind::Group)
5957 .height(Size::Fill(1.0))
5958 .width(Size::Aspect(2.0))])
5959 .width(Size::Fixed(800.0))
5960 .height(Size::Fixed(200.0));
5961 let mut state = UiState::new();
5962 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
5963 let r = state.rect(&root.children[0].computed_id);
5964 assert!(
5965 (r.h - 200.0).abs() < 0.5,
5966 "expected h≈200 (Fill), got {}",
5967 r.h,
5968 );
5969 assert!(
5970 (r.w - 400.0).abs() < 0.5,
5971 "expected w≈400 (Aspect 2.0 of 200), got {}",
5972 r.w,
5973 );
5974 }
5975
5976 #[test]
5979 fn aspect_on_both_axes_falls_back_to_intrinsic() {
5980 let mut root = column([crate::widgets::text::text("hi")
5981 .width(Size::Aspect(1.0))
5982 .height(Size::Aspect(1.0))])
5983 .width(Size::Fixed(200.0))
5984 .height(Size::Fixed(200.0));
5985 let mut state = UiState::new();
5986 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
5987 let r = state.rect(&root.children[0].computed_id);
5988 assert!(
5989 r.w > 0.0 && r.h > 0.0,
5990 "expected finite size for both-Aspect fallback, got {}x{}",
5991 r.w,
5992 r.h,
5993 );
5994 }
5995
5996 #[test]
6000 fn aspect_respects_min_and_max_on_derived_axis() {
6001 let mut root = column([column([El::new(Kind::Group)
6005 .width(Size::Fill(1.0))
6006 .height(Size::Aspect(1.0))
6007 .max_height(120.0)])
6008 .width(Size::Hug)
6009 .height(Size::Hug)])
6010 .width(Size::Fixed(400.0))
6011 .height(Size::Fixed(600.0));
6012 let mut state = UiState::new();
6013 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6014 let panel = state.rect(&root.children[0].computed_id);
6015 let img = state.rect(&root.children[0].children[0].computed_id);
6016 assert!(
6017 (img.h - 120.0).abs() < 0.5,
6018 "max_height should clamp aspect-derived height to 120, got {}",
6019 img.h,
6020 );
6021 assert!(
6022 (panel.h - 120.0).abs() < 0.5,
6023 "hugging panel should match clamped child (120), got {}",
6024 panel.h,
6025 );
6026
6027 let mut root = column([column([El::new(Kind::Group)
6030 .width(Size::Fill(1.0))
6031 .height(Size::Aspect(0.1))
6032 .min_height(200.0)])
6033 .width(Size::Hug)
6034 .height(Size::Hug)])
6035 .width(Size::Fixed(400.0))
6036 .height(Size::Fixed(600.0));
6037 let mut state = UiState::new();
6038 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6039 let panel = state.rect(&root.children[0].computed_id);
6040 let img = state.rect(&root.children[0].children[0].computed_id);
6041 assert!(
6042 (img.h - 200.0).abs() < 0.5,
6043 "min_height should bump aspect-derived height to 200, got {}",
6044 img.h,
6045 );
6046 assert!(
6047 (panel.h - 200.0).abs() < 0.5,
6048 "hugging panel should match bumped child (200), got {}",
6049 panel.h,
6050 );
6051 }
6052
6053 #[test]
6056 fn aspect_basis_is_clamped_before_deriving() {
6057 let mut root = column([El::new(Kind::Group)
6063 .width(Size::Fill(1.0))
6064 .height(Size::Aspect(0.5))
6065 .max_width(100.0)])
6066 .width(Size::Fixed(400.0))
6067 .height(Size::Fixed(400.0));
6068 let mut state = UiState::new();
6069 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6070 let img = state.rect(&root.children[0].computed_id);
6071 assert!(
6072 (img.w - 100.0).abs() < 0.5,
6073 "max_width should cap Fill width at 100, got {}",
6074 img.w,
6075 );
6076 assert!(
6077 (img.h - 50.0).abs() < 0.5,
6078 "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
6079 img.h,
6080 );
6081 }
6082
6083 #[test]
6089 fn hug_column_around_fill_aspect_child_does_not_overflow() {
6090 let mut root = column([column([El::new(Kind::Group)
6097 .width(Size::Fill(1.0))
6098 .height(Size::Aspect(0.5))])
6099 .width(Size::Hug)
6100 .height(Size::Hug)])
6101 .width(Size::Fixed(400.0))
6102 .height(Size::Fixed(400.0));
6103 let mut state = UiState::new();
6104 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6105 let panel = state.rect(&root.children[0].computed_id);
6106 let img = state.rect(&root.children[0].children[0].computed_id);
6107 assert!(
6108 (panel.h - 200.0).abs() < 0.5,
6109 "hugging panel should hug to aspect-derived height 200, got {}",
6110 panel.h,
6111 );
6112 assert!(
6113 (img.h - 200.0).abs() < 0.5,
6114 "image should layout to height 200, got {}",
6115 img.h,
6116 );
6117 assert!(
6118 img.bottom() <= panel.bottom() + 0.5,
6119 "image (bottom={}) must fit within hugging panel (bottom={})",
6120 img.bottom(),
6121 panel.bottom(),
6122 );
6123 }
6124
6125 #[test]
6129 fn hugging_parent_sees_aspect_corrected_intrinsic() {
6130 let mut root = column([column([El::new(Kind::Group)
6134 .width(Size::Fixed(80.0))
6135 .height(Size::Aspect(0.5))])
6136 .width(Size::Hug)
6137 .height(Size::Hug)])
6138 .width(Size::Fixed(400.0))
6139 .height(Size::Fixed(400.0))
6140 .align(Align::Start);
6141 let mut state = UiState::new();
6142 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6143 let hugger = state.rect(&root.children[0].computed_id);
6144 assert!(
6145 (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
6146 "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
6147 hugger.w,
6148 hugger.h,
6149 );
6150 }
6151
6152 #[test]
6154 fn max_height_caps_overlay_child_below_intrinsic() {
6155 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
6158 .width(Size::Fixed(40.0))
6159 .height(Size::Fixed(300.0))])
6160 .width(Size::Hug)
6161 .height(Size::Hug)
6162 .max_height(100.0)])
6163 .width(Size::Fixed(600.0))
6164 .height(Size::Fixed(600.0));
6165 let mut state = UiState::new();
6166 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
6167 let child_rect = state.rect(&root.children[0].computed_id);
6168 assert!(
6169 (child_rect.h - 100.0).abs() < 0.5,
6170 "expected child height capped at 100, got h={}",
6171 child_rect.h,
6172 );
6173 }
6174
6175 #[test]
6179 fn user_resizable_publishes_trailing_edge_band() {
6180 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6181 let mut root = crate::tree::row([
6182 column(Vec::<El>::new())
6183 .key("nav")
6184 .user_resizable()
6185 .width(Size::Fixed(200.0))
6186 .min_width(120.0)
6187 .max_width(420.0),
6188 column(Vec::<El>::new()).width(Size::Fill(1.0)),
6189 ])
6190 .height(Size::Fixed(400.0));
6191 let mut state = UiState::new();
6192 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6193
6194 assert_eq!(state.resize.bands.len(), 1);
6195 let band = &state.resize.bands[0];
6196 assert_eq!(band.id, "nav");
6197 assert_eq!(band.key.as_deref(), Some("nav"));
6198 assert_eq!(band.sign, 1.0, "trailing edge: drag-right grows");
6199 assert!((band.current - 200.0).abs() < 0.5);
6200 assert_eq!((band.min, band.max), (120.0, 420.0));
6201 assert!((band.band.x - (200.0 - T / 2.0)).abs() < 0.5);
6203 assert!((band.band.w - T).abs() < 0.5);
6204 assert!((band.band.h - 400.0).abs() < 0.5);
6205 }
6206
6207 #[test]
6212 fn user_resizable_last_child_gets_leading_edge_band() {
6213 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6214 let mut root = crate::tree::row([
6215 column(Vec::<El>::new()).width(Size::Fill(1.0)),
6216 column(Vec::<El>::new())
6217 .key("inspector")
6218 .user_resizable()
6219 .width(Size::Fixed(240.0)),
6220 ])
6221 .height(Size::Fixed(400.0));
6222 let mut state = UiState::new();
6223 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6224
6225 assert_eq!(state.resize.bands.len(), 1);
6226 let band = &state.resize.bands[0];
6227 assert_eq!(band.sign, -1.0, "leading edge: drag-left grows");
6228 assert!((band.band.x - (560.0 - T / 2.0)).abs() < 0.5);
6230 assert_eq!(band.min, 0.0);
6231 assert!(
6232 (band.max - 800.0).abs() < 0.5,
6233 "no max_width → capped at the parent's inner extent, got {}",
6234 band.max,
6235 );
6236 }
6237
6238 #[test]
6242 fn user_resizable_override_applies_and_clamps() {
6243 let build = || {
6244 crate::tree::row([
6245 column(Vec::<El>::new())
6246 .key("nav")
6247 .user_resizable()
6248 .width(Size::Fixed(200.0))
6249 .min_width(120.0)
6250 .max_width(420.0),
6251 column(Vec::<El>::new()).key("main").width(Size::Fill(1.0)),
6252 ])
6253 .height(Size::Fixed(400.0))
6254 };
6255 let mut state = UiState::new();
6256 state.set_user_size("nav", 300.0);
6257 let mut root = build();
6258 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6259 let nav = state.rect_of_key("nav").unwrap();
6260 assert!((nav.w - 300.0).abs() < 0.5, "override wins, got {}", nav.w);
6261 let main = state.rect_of_key("main").unwrap();
6262 assert!(
6263 (main.w - 500.0).abs() < 0.5,
6264 "the Fill sibling absorbs the change, got {}",
6265 main.w,
6266 );
6267
6268 state.set_user_size("nav", 9999.0);
6270 let mut root = build();
6271 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6272 assert!((state.rect_of_key("nav").unwrap().w - 420.0).abs() < 0.5);
6273
6274 state.clear_user_size("nav");
6276 let mut root = build();
6277 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6278 assert!((state.rect_of_key("nav").unwrap().w - 200.0).abs() < 0.5);
6279 }
6280
6281 #[test]
6285 fn user_resizable_in_column_resizes_height() {
6286 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6287 let mut root = column([
6288 crate::tree::row(Vec::<El>::new())
6289 .key("topbar")
6290 .user_resizable()
6291 .height(Size::Fixed(100.0))
6292 .min_height(40.0),
6293 crate::tree::row(Vec::<El>::new()).height(Size::Fill(1.0)),
6294 ])
6295 .width(Size::Fixed(800.0));
6296 let mut state = UiState::new();
6297 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6298
6299 assert_eq!(state.resize.bands.len(), 1);
6300 let band = &state.resize.bands[0];
6301 assert!((band.band.y - (100.0 - T / 2.0)).abs() < 0.5);
6302 assert!((band.band.w - 800.0).abs() < 0.5);
6303 assert_eq!(band.min, 40.0);
6304 assert!((band.current - 100.0).abs() < 0.5);
6305
6306 state.set_user_size("topbar", 250.0);
6307 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6308 assert!((state.rect_of_key("topbar").unwrap().h - 250.0).abs() < 0.5);
6309 }
6310}