1#![warn(missing_docs)]
36
37use std::cell::RefCell;
38use std::sync::Arc;
39
40use rustc_hash::FxHashSet;
41
42use crate::scroll::{ScrollAlignment, ScrollRequest};
43use crate::state::dyn_height::DynHeightIndex;
44use crate::state::{ScrollAnchor, UiState, VirtualAnchor};
45use crate::text::metrics as text_metrics;
46use crate::tree::*;
47
48#[derive(Clone)]
78pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
79
80impl LayoutFn {
81 pub fn new<F>(f: F) -> Self
84 where
85 F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
86 {
87 LayoutFn(Arc::new(f))
88 }
89}
90
91impl std::fmt::Debug for LayoutFn {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str("LayoutFn(<fn>)")
94 }
95}
96
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub struct LayoutIntrinsicCacheStats {
105 pub hits: u64,
107 pub misses: u64,
109}
110
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
116pub struct LayoutPruneStats {
117 pub subtrees: u64,
119 pub nodes: u64,
121}
122
123thread_local! {
124 static SIZING_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
129 static LAST_SIZING_STATS: RefCell<LayoutIntrinsicCacheStats> =
130 const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
131 static PRUNE_STATS: RefCell<LayoutPruneStats> =
132 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
133 static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
134 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
135}
136
137pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
142 LAST_SIZING_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
143}
144
145pub fn take_prune_stats() -> LayoutPruneStats {
149 LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
150}
151
152#[derive(Clone, Debug)]
175pub enum VirtualMode {
176 Fixed {
178 row_height: f32,
180 },
181 Dynamic {
185 estimated_row_height: f32,
188 append_only: bool,
201 },
202}
203
204#[derive(Clone, Copy, Debug, PartialEq)]
208pub enum VirtualAnchorPolicy {
209 ViewportFraction {
212 y_fraction: f32,
215 },
216 FirstVisible,
219 LastVisible,
222}
223
224impl Default for VirtualAnchorPolicy {
225 fn default() -> Self {
226 Self::ViewportFraction { y_fraction: 0.25 }
227 }
228}
229
230#[derive(Clone)]
237#[non_exhaustive]
238pub struct VirtualItems {
239 pub count: usize,
241 pub mode: VirtualMode,
243 pub anchor_policy: VirtualAnchorPolicy,
246 pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
250 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
253}
254
255impl VirtualItems {
256 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
260 where
261 F: Fn(usize) -> El + Send + Sync + 'static,
262 {
263 assert!(
264 row_height > 0.0,
265 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
266 );
267 VirtualItems {
268 count,
269 mode: VirtualMode::Fixed { row_height },
270 anchor_policy: VirtualAnchorPolicy::default(),
271 row_key: Arc::new(|i| i.to_string()),
272 build_row: Arc::new(build_row),
273 }
274 }
275
276 pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
282 where
283 K: Fn(usize) -> String + Send + Sync + 'static,
284 F: Fn(usize) -> El + Send + Sync + 'static,
285 {
286 assert!(
287 estimated_row_height > 0.0,
288 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
289 );
290 VirtualItems {
291 count,
292 mode: VirtualMode::Dynamic {
293 estimated_row_height,
294 append_only: false,
295 },
296 anchor_policy: VirtualAnchorPolicy::default(),
297 row_key: Arc::new(row_key),
298 build_row: Arc::new(build_row),
299 }
300 }
301
302 pub fn append_only(mut self) -> Self {
306 if let VirtualMode::Dynamic {
307 ref mut append_only,
308 ..
309 } = self.mode
310 {
311 *append_only = true;
312 }
313 self
314 }
315
316 pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
319 self.anchor_policy = policy;
320 self
321 }
322}
323
324impl std::fmt::Debug for VirtualItems {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 f.debug_struct("VirtualItems")
327 .field("count", &self.count)
328 .field("mode", &self.mode)
329 .field("anchor_policy", &self.anchor_policy)
330 .field("row_key", &"<fn>")
331 .field("build_row", &"<fn>")
332 .finish()
333 }
334}
335
336#[non_exhaustive]
341pub struct LayoutCtx<'a> {
342 pub container: Rect,
346 pub children: &'a [El],
349 pub measure: &'a dyn Fn(&El) -> (f32, f32),
353 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
360 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
370}
371
372pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
381 {
382 crate::profile_span!("layout::assign_ids");
383 assign_ids(root);
384 }
385 layout_post_assign(root, ui_state, viewport);
386}
387
388pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
394 SIZING_VISITS.with(|c| c.set(0));
395 PRUNE_STATS.with(|s| *s.borrow_mut() = LayoutPruneStats::default());
396 {
397 crate::profile_span!("layout::root_setup");
398 root.computed_rect = viewport;
399 ui_state
403 .layout
404 .keyed_rects
405 .insert(root.computed_id.clone(), viewport);
406 rebuild_key_index(root, ui_state);
407 ui_state.scroll.metrics.clear();
411 ui_state.scroll.thumb_rects.clear();
412 ui_state.scroll.thumb_tracks.clear();
413 ui_state.scroll.visible_ranges.clear();
414 ui_state.resize.bands.clear();
415 apply_size_rewrites(root, ui_state, None);
422 }
423 {
424 crate::profile_span!("layout::size");
431 size_tree(root, Some(viewport.w));
432 }
433 {
434 crate::profile_span!("layout::children");
435 layout_children(root, viewport, ui_state);
436 }
437 publish_resize_bands(root, ui_state);
439 LAST_SIZING_STATS.with(|s| {
440 *s.borrow_mut() = LayoutIntrinsicCacheStats {
441 hits: 0,
442 misses: SIZING_VISITS.with(|c| c.get()),
443 };
444 });
445 LAST_PRUNE_STATS.with(|last| *last.borrow_mut() = PRUNE_STATS.with(|s| *s.borrow()));
446}
447
448fn resize_clamp(child: &El, axis: Axis) -> (f32, f32) {
453 let (min, max) = match axis {
454 Axis::Column => (child.min_height, child.max_height),
455 _ => (child.min_width, child.max_width),
456 };
457 let min = min.unwrap_or(0.0).max(0.0);
458 (min, max.unwrap_or(f32::INFINITY).max(min))
459}
460
461fn apply_size_rewrites(node: &mut El, ui_state: &UiState, parent_axis: Option<Axis>) {
470 if node.user_resizable
471 && let Some(axis) = parent_axis
472 && !matches!(axis, Axis::Overlay)
473 {
474 let id = node.key.as_deref().unwrap_or(&node.computed_id);
475 if let Some(&px) = ui_state.resize.overrides.get(id) {
476 let (min, max) = resize_clamp(node, axis);
477 let px = px.clamp(min, max);
478 match axis {
479 Axis::Column => node.height = Size::Fixed(px),
480 _ => node.width = Size::Fixed(px),
481 }
482 }
483 }
484 if let Size::Ch(n) = node.width {
485 node.width = Size::Fixed((n * ch_unit(node)).max(0.0));
486 }
487 if let Size::Ch(n) = node.height {
488 node.height = Size::Fixed((n * ch_unit(node)).max(0.0));
489 }
490 let axis = node.axis;
491 for child in &mut node.children {
492 apply_size_rewrites(child, ui_state, Some(axis));
493 }
494}
495
496fn ch_unit(node: &El) -> f32 {
501 text_metrics::layout_text_with_family(
502 "0",
503 node.font_size,
504 node.font_family,
505 node.font_weight,
506 node.font_mono,
507 node.text_tabular_numerals,
508 TextWrap::NoWrap,
509 None,
510 )
511 .width
512 .max(0.0)
513}
514
515fn publish_resize_bands(node: &El, ui_state: &mut UiState) {
523 use crate::state::resize::{RESIZE_BAND_THICKNESS as T, ResizeBand};
524 let axis = node.axis;
525 if !matches!(axis, Axis::Overlay) && node.children.iter().any(|c| c.user_resizable) {
526 let parent_rect = node.computed_rect;
527 let inner_main = match axis {
528 Axis::Column => parent_rect.h - node.padding.top - node.padding.bottom,
529 _ => parent_rect.w - node.padding.left - node.padding.right,
530 };
531 let count = node.children.len();
532 for (idx, child) in node.children.iter().enumerate() {
533 if !child.user_resizable {
534 continue;
535 }
536 let rect = child.computed_rect;
537 let trailing = idx + 1 < count || count == 1;
541 let band = match (axis, trailing) {
542 (Axis::Column, true) => Rect::new(rect.x, rect.y + rect.h - T / 2.0, rect.w, T),
543 (Axis::Column, false) => Rect::new(rect.x, rect.y - T / 2.0, rect.w, T),
544 (_, true) => Rect::new(rect.x + rect.w - T / 2.0, rect.y, T, rect.h),
545 (_, false) => Rect::new(rect.x - T / 2.0, rect.y, T, rect.h),
546 };
547 let (min, max) = resize_clamp(child, axis);
548 let max = max.min(inner_main.max(min));
552 ui_state.resize.bands.push(ResizeBand {
553 id: child
554 .key
555 .clone()
556 .unwrap_or_else(|| child.computed_id.to_string()),
557 key: child.key.clone(),
558 container_id: node.computed_id.to_string(),
559 band,
560 axis,
561 sign: if trailing { 1.0 } else { -1.0 },
562 current: match axis {
563 Axis::Column => rect.h,
564 _ => rect.w,
565 },
566 min,
567 max,
568 });
569 }
570 }
571 for child in &node.children {
572 publish_resize_bands(child, ui_state);
573 }
574}
575
576pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
583 let mut path = String::with_capacity(parent_id.len() + 24);
584 path.push_str(parent_id);
585 push_id_suffix(&mut path, child, child_index);
586 assign_id(child, &mut path);
587}
588
589fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
594 ui_state.layout.key_index.clear();
595 let mut id_counts: rustc_hash::FxHashMap<&str, u32> = Default::default();
603 fn visit<'a>(
604 node: &'a El,
605 index: &mut rustc_hash::FxHashMap<String, std::sync::Arc<str>>,
606 id_counts: &mut rustc_hash::FxHashMap<&'a str, u32>,
607 ) {
608 if let Some(key) = &node.key {
609 index
610 .entry(key.clone())
611 .or_insert_with(|| node.computed_id.clone());
612 }
613 *id_counts.entry(node.computed_id.as_ref()).or_insert(0) += 1;
614 for c in &node.children {
615 visit(c, index, id_counts);
616 }
617 }
618 visit(root, &mut ui_state.layout.key_index, &mut id_counts);
619 warn_duplicate_ids(&id_counts, &mut ui_state.layout.warned_duplicate_ids);
620}
621
622fn warn_duplicate_ids(
629 id_counts: &rustc_hash::FxHashMap<&str, u32>,
630 warned: &mut rustc_hash::FxHashSet<String>,
631) {
632 for (&id, &n) in id_counts {
633 if n > 1 && warned.insert(id.to_string()) {
634 log::warn!(
635 "DuplicateId: {n} nodes share id {id} — duplicate sibling key; \
636 their rects and intrinsic-cache entries collide silently (issue #64)"
637 );
638 }
639 }
640}
641
642pub fn assign_ids(root: &mut El) {
646 let mut path = String::with_capacity(128);
647 path.push_str("root");
648 assign_id(root, &mut path);
649}
650
651fn push_id_suffix(path: &mut String, child: &El, child_index: usize) {
654 use std::fmt::Write;
655 path.push('.');
656 path.push_str(role_token(&child.kind));
657 match &child.key {
658 Some(k) => {
659 path.push('[');
660 path.push_str(k);
661 path.push(']');
662 }
663 None => {
664 let _ = write!(path, ".{child_index}");
665 }
666 }
667}
668
669fn assign_id(node: &mut El, path: &mut String) {
673 node.computed_id = std::sync::Arc::from(path.as_str());
674 for (i, c) in node.children.iter_mut().enumerate() {
675 let len = path.len();
676 push_id_suffix(path, c, i);
677 assign_id(c, path);
678 path.truncate(len);
679 }
680}
681
682fn role_token(k: &Kind) -> &'static str {
683 match k {
684 Kind::Group => "group",
685 Kind::Card => "card",
686 Kind::Button => "button",
687 Kind::Badge => "badge",
688 Kind::Text => "text",
689 Kind::Heading => "heading",
690 Kind::Spacer => "spacer",
691 Kind::Divider => "divider",
692 Kind::Overlay => "overlay",
693 Kind::Scrim => "scrim",
694 Kind::Modal => "modal",
695 Kind::Scroll => "scroll",
696 Kind::VirtualList => "virtual_list",
697 Kind::Inlines => "inlines",
698 Kind::HardBreak => "hard_break",
699 Kind::Math => "math",
700 Kind::Image => "image",
701 Kind::Surface => "surface",
702 Kind::Vector => "vector",
703 Kind::Scene3D => "scene3d",
704 Kind::Plot => "plot",
705 Kind::Viewport => "viewport",
706 Kind::Custom(name) => name,
707 }
708}
709
710#[inline]
716fn set_rect(node: &mut El, rect: Rect, ui_state: &mut UiState) {
717 node.computed_rect = rect;
718 if node.key.is_some() {
719 ui_state
720 .layout
721 .keyed_rects
722 .insert(node.computed_id.clone(), rect);
723 }
724}
725
726fn find_descendant_rect(node: &El, target_id: &str) -> Option<Rect> {
734 for c in &node.children {
735 if &*c.computed_id == target_id {
736 return Some(c.computed_rect);
737 }
738 if target_id
739 .strip_prefix(&*c.computed_id)
740 .is_some_and(|rest| rest.starts_with('.'))
741 {
742 return find_descendant_rect(c, target_id);
743 }
744 }
745 None
746}
747
748fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
749 if matches!(node.kind, Kind::Inlines) {
750 for c in &mut node.children {
758 set_rect(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
759 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
763 }
764 return;
765 }
766 if let Some(items) = node.virtual_items.as_deref().cloned() {
767 layout_virtual(node, node_rect, items, ui_state);
768 return;
769 }
770 if let Some(layout_fn) = node.layout_override.clone() {
771 layout_custom(node, node_rect, layout_fn, ui_state);
772 if node.scrollable {
773 apply_scroll_offset(node, node_rect, ui_state);
774 }
775 if node.viewport.is_some() {
776 apply_viewport_transform(node, node_rect, ui_state);
777 }
778 return;
779 }
780 match node.axis {
781 Axis::Overlay => {
782 let inner = node_rect.inset(node.padding);
783 let clamp_to_parent = node.viewport.is_none();
787 for c in &mut node.children {
788 let c_rect = overlay_rect(c, inner, node.align, node.justify, clamp_to_parent);
789 resize_if_width_diverged(c, c_rect.w);
790 set_rect(c, c_rect, ui_state);
791 layout_children(c, c_rect, ui_state);
792 }
793 }
794 Axis::Column => layout_axis(node, node_rect, true, ui_state),
795 Axis::Row => layout_axis(node, node_rect, false, ui_state),
796 }
797 if node.scrollable {
798 apply_scroll_offset(node, node_rect, ui_state);
799 }
800 if node.viewport.is_some() {
801 apply_viewport_transform(node, node_rect, ui_state);
802 }
803}
804
805fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
806 let inner = node_rect.inset(node.padding);
807 let measure = |c: &El| c.measured_size;
811 let key_index = &ui_state.layout.key_index;
818 let keyed_rects = &ui_state.layout.keyed_rects;
819 let rect_of_key = |key: &str| -> Option<Rect> {
820 let id = key_index.get(key)?;
821 keyed_rects.get(id).copied()
822 };
823 let rect_of_id = |id: &str| -> Option<Rect> { keyed_rects.get(id).copied() };
824 let rects = (layout_fn.0)(LayoutCtx {
825 container: inner,
826 children: &node.children,
827 measure: &measure,
828 rect_of_key: &rect_of_key,
829 rect_of_id: &rect_of_id,
830 });
831 assert_eq!(
832 rects.len(),
833 node.children.len(),
834 "LayoutFn for {:?} returned {} rects for {} children",
835 node.computed_id,
836 rects.len(),
837 node.children.len(),
838 );
839 for (c, c_rect) in node.children.iter_mut().zip(rects) {
840 resize_if_width_diverged(c, c_rect.w);
844 set_rect(c, c_rect, ui_state);
845 layout_children(c, c_rect, ui_state);
846 }
847}
848
849fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
855 let inner = node_rect.inset(node.padding);
856 match items.mode {
857 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
858 node,
859 inner,
860 items.count,
861 row_height,
862 items.build_row,
863 ui_state,
864 ),
865 VirtualMode::Dynamic {
866 estimated_row_height,
867 append_only,
868 } => {
869 let fns = DynamicVirtualFns {
870 anchor_policy: items.anchor_policy,
871 row_key: items.row_key,
872 build_row: items.build_row,
873 };
874 if append_only {
875 layout_virtual_dynamic_incremental(
876 node,
877 inner,
878 items.count,
879 estimated_row_height,
880 fns,
881 ui_state,
882 );
883 } else {
884 layout_virtual_dynamic(
885 node,
886 inner,
887 items.count,
888 estimated_row_height,
889 fns,
890 ui_state,
891 );
892 }
893 }
894 }
895}
896
897fn resolve_scroll_requests<F, K>(
907 node: &El,
908 inner: Rect,
909 count: usize,
910 row_extent: F,
911 row_for_key: K,
912 ui_state: &mut UiState,
913) -> bool
914where
915 F: Fn(usize) -> (f32, f32),
916 K: Fn(&str) -> Option<usize>,
917{
918 if ui_state.scroll.pending_requests.is_empty() {
919 return false;
920 }
921 let Some(key) = node.key.as_deref() else {
922 return false;
923 };
924 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
925 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
926 pending.into_iter().partition(|req| match req {
927 ScrollRequest::ToRow { list_key, .. } => list_key == key,
928 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
929 ScrollRequest::EnsureVisible { .. } => false,
932 });
933 ui_state.scroll.pending_requests = remaining;
934
935 let mut wrote = false;
936 for req in matched {
937 let (row, align) = match req {
938 ScrollRequest::ToRow { row, align, .. } => (row, align),
939 ScrollRequest::ToRowKey { row_key, align, .. } => {
940 let Some(row) = row_for_key(&row_key) else {
941 continue;
942 };
943 (row, align)
944 }
945 ScrollRequest::EnsureVisible { .. } => continue,
946 };
947 if row >= count {
948 continue;
949 }
950 let (row_top, row_h) = row_extent(row);
951 let row_bottom = row_top + row_h;
952 let viewport_h = inner.h;
953 let current = ui_state
954 .scroll
955 .offsets
956 .get(&*node.computed_id)
957 .copied()
958 .unwrap_or(0.0);
959 let new_offset = match align {
960 ScrollAlignment::Start => row_top,
961 ScrollAlignment::End => row_bottom - viewport_h,
962 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
963 ScrollAlignment::Visible => {
964 if row_top < current {
965 row_top
966 } else if row_bottom > current + viewport_h {
967 row_bottom - viewport_h
968 } else {
969 continue;
970 }
971 }
972 };
973 ui_state
974 .scroll
975 .offsets
976 .insert(node.computed_id.to_string(), new_offset);
977 wrote = true;
978 }
979 wrote
980}
981
982fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
985 let max_offset = (total_h - inner.h).max(0.0);
986 let stored = ui_state
987 .scroll
988 .offsets
989 .get(&*node.computed_id)
990 .copied()
991 .unwrap_or(0.0);
992 let stored = resolve_pin(node, stored, max_offset, ui_state);
993 let offset = stored.clamp(0.0, max_offset);
994 ui_state
995 .scroll
996 .offsets
997 .insert(node.computed_id.to_string(), offset);
998 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
999 offset
1000}
1001
1002fn write_virtual_scroll_metrics(
1003 node: &El,
1004 inner: Rect,
1005 total_h: f32,
1006 max_offset: f32,
1007 offset: f32,
1008 ui_state: &mut UiState,
1009) {
1010 ui_state.scroll.metrics.insert(
1011 node.computed_id.to_string(),
1012 crate::state::ScrollMetrics {
1013 viewport_h: inner.h,
1014 content_h: total_h,
1015 max_offset,
1016 },
1017 );
1018 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
1019}
1020
1021fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
1025 let role = role_token(&child.kind);
1026 let mut path = String::with_capacity(parent_id.len() + 24);
1027 path.push_str(parent_id);
1028 path.push('.');
1029 path.push_str(role);
1030 match &child.key {
1031 Some(k) => {
1032 path.push('[');
1033 path.push_str(k);
1034 path.push(']');
1035 }
1036 None => {
1037 use std::fmt::Write;
1038 let _ = write!(path, ".{global_i}");
1039 }
1040 }
1041 assign_id(child, &mut path);
1042}
1043
1044fn layout_virtual_fixed(
1045 node: &mut El,
1046 inner: Rect,
1047 count: usize,
1048 row_height: f32,
1049 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1050 ui_state: &mut UiState,
1051) {
1052 let gap = node.gap.max(0.0);
1053 let pitch = row_height + gap;
1054 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
1055 resolve_scroll_requests(
1056 node,
1057 inner,
1058 count,
1059 |i| (i as f32 * pitch, row_height),
1060 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
1061 ui_state,
1062 );
1063 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
1064
1065 if count == 0 {
1066 node.children.clear();
1067 return;
1068 }
1069
1070 let start = (offset / pitch).floor() as usize;
1074 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
1075
1076 let mut realized: Vec<El> = Vec::new();
1077 let mut realized_range: Option<(usize, usize)> = None;
1078 for global_i in start..end {
1079 let row_top = global_i as f32 * pitch;
1080 if row_top >= offset + inner.h || row_top + row_height <= offset {
1081 continue;
1082 }
1083 let mut child = (build_row)(global_i);
1084 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
1085 size_tree(&mut child, Some(inner.w));
1088
1089 let row_y = inner.y + row_top - offset;
1090 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
1091 set_rect(&mut child, c_rect, ui_state);
1092 layout_children(&mut child, c_rect, ui_state);
1093 realized.push(child);
1094 realized_range = Some(match realized_range {
1095 None => (global_i, global_i + 1),
1096 Some((s, _)) => (s, global_i + 1),
1097 });
1098 }
1099 if let Some((s, e)) = realized_range {
1100 ui_state
1101 .scroll
1102 .visible_ranges
1103 .insert(node.computed_id.to_string(), (s, e));
1104 }
1105 node.children = realized;
1106}
1107
1108fn layout_virtual_dynamic_incremental(
1120 node: &mut El,
1121 inner: Rect,
1122 count: usize,
1123 estimated_row_height: f32,
1124 fns: DynamicVirtualFns,
1125 ui_state: &mut UiState,
1126) {
1127 let gap = node.gap.max(0.0);
1128 let width_bucket = virtual_width_bucket(inner.w);
1129
1130 if count == 0 {
1131 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1132 ui_state.scroll.dyn_height_index.remove(&*node.computed_id);
1133 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1134 debug_assert_eq!(offset, 0.0);
1135 node.children.clear();
1136 return;
1137 }
1138
1139 let existing = ui_state.scroll.dyn_height_index.remove(&*node.computed_id);
1146 let head_key = (fns.row_key)(0);
1147 let mut trimmed_keys: Vec<String> = Vec::new();
1148 let reconciled = existing.and_then(|mut ix| {
1149 let ok = ix.reconcile(
1150 width_bucket,
1151 estimated_row_height,
1152 count,
1153 &head_key,
1154 |i| (fns.row_key)(i),
1155 |_i, key| {
1156 cached_row_height(
1157 ui_state,
1158 &node.computed_id,
1159 key,
1160 width_bucket,
1161 estimated_row_height,
1162 )
1163 },
1164 &mut trimmed_keys,
1165 );
1166 ok.then_some(ix)
1167 });
1168 let mut index = match reconciled {
1169 Some(ix) => ix,
1170 None => DynHeightIndex::build(width_bucket, estimated_row_height, count, |i| {
1171 let key = (fns.row_key)(i);
1172 let h = cached_row_height(
1173 ui_state,
1174 &node.computed_id,
1175 &key,
1176 width_bucket,
1177 estimated_row_height,
1178 );
1179 (key, h)
1180 }),
1181 };
1182 if !trimmed_keys.is_empty() {
1187 if let Some(measured) = ui_state
1188 .scroll
1189 .measured_row_heights
1190 .get_mut(&*node.computed_id)
1191 {
1192 for key in &trimmed_keys {
1193 measured.remove(key);
1194 }
1195 if measured.is_empty() {
1196 ui_state
1197 .scroll
1198 .measured_row_heights
1199 .remove(&*node.computed_id);
1200 }
1201 }
1202 }
1203
1204 let has_request = node.key.as_deref().is_some_and(|k| {
1207 ui_state.scroll.pending_requests.iter().any(|r| match r {
1208 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1209 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1210 ScrollRequest::EnsureVisible { .. } => false,
1211 })
1212 });
1213 let mut request_wrote = false;
1214 if has_request {
1215 request_wrote = resolve_scroll_requests(
1216 node,
1217 inner,
1218 count,
1219 |target| (index.row_top(target, gap), index.height(target)),
1220 |row_key| index.index_for_key(row_key),
1221 ui_state,
1222 );
1223 }
1224
1225 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1226 let max_offset = (total_h - inner.h).max(0.0);
1227 let stored = ui_state
1228 .scroll
1229 .offsets
1230 .get(&*node.computed_id)
1231 .copied()
1232 .unwrap_or(0.0);
1233 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1234 let provisional_offset = if pin_active {
1235 match node.pin_policy {
1236 crate::tree::PinPolicy::End => max_offset,
1237 crate::tree::PinPolicy::Start => 0.0,
1238 crate::tree::PinPolicy::None => unreachable!(),
1239 }
1240 } else if request_wrote {
1241 stored
1242 } else {
1243 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1244 }
1245 .clamp(0.0, max_offset);
1246
1247 let (measure_start, _, measure_end) = index.visible_range(gap, provisional_offset, inner.h);
1248 measure_dynamic_range(
1249 node,
1250 DynamicRangeCtx {
1251 inner,
1252 keys: KeySource::Func(&*fns.row_key),
1253 width_bucket,
1254 build_row: &fns.build_row,
1255 },
1256 measure_start,
1257 measure_end,
1258 ui_state,
1259 );
1260 refresh_index_range(
1261 &mut index,
1262 &node.computed_id,
1263 width_bucket,
1264 measure_start,
1265 measure_end,
1266 &*fns.row_key,
1267 estimated_row_height,
1268 ui_state,
1269 );
1270
1271 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1272 let max_offset = (total_h - inner.h).max(0.0);
1273 let stored = ui_state
1274 .scroll
1275 .offsets
1276 .get(&*node.computed_id)
1277 .copied()
1278 .unwrap_or(0.0);
1279 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1280 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1281 && ui_state
1282 .scroll
1283 .pin_active
1284 .get(&*node.computed_id)
1285 .copied()
1286 .unwrap_or(false);
1287 let mut offset = if pin_active {
1288 pin_resolved
1289 } else if request_wrote {
1290 stored
1291 } else {
1292 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(stored)
1293 }
1294 .clamp(0.0, max_offset);
1295
1296 ui_state
1297 .scroll
1298 .offsets
1299 .insert(node.computed_id.to_string(), offset);
1300
1301 let (start, start_y, end) = index.visible_range(gap, offset, inner.h);
1302 let mut realized_rows = layout_dynamic_range(
1303 node,
1304 DynamicRangeCtx {
1305 inner,
1306 keys: KeySource::Func(&*fns.row_key),
1307 width_bucket,
1308 build_row: &fns.build_row,
1309 },
1310 offset,
1311 start,
1312 start_y,
1313 end,
1314 ui_state,
1315 );
1316 refresh_index_range(
1317 &mut index,
1318 &node.computed_id,
1319 width_bucket,
1320 start,
1321 end,
1322 &*fns.row_key,
1323 estimated_row_height,
1324 ui_state,
1325 );
1326
1327 let total_h = virtual_total_height(count, index.heights_sum(), gap);
1328 let max_offset = (total_h - inner.h).max(0.0);
1329 let corrected_offset = if pin_active {
1330 match node.pin_policy {
1331 crate::tree::PinPolicy::End => max_offset,
1332 crate::tree::PinPolicy::Start => 0.0,
1333 crate::tree::PinPolicy::None => unreachable!(),
1334 }
1335 } else if request_wrote {
1336 offset
1337 } else {
1338 dynamic_anchor_offset_indexed(node, &index, gap, stored, ui_state).unwrap_or(offset)
1339 }
1340 .clamp(0.0, max_offset);
1341 if (corrected_offset - offset).abs() > 0.01 {
1342 let dy = offset - corrected_offset;
1343 for child in &mut node.children {
1344 shift_subtree_y(child, dy, ui_state);
1345 }
1346 for row in &mut realized_rows {
1347 row.rect.y += dy;
1348 }
1349 offset = corrected_offset;
1350 ui_state
1351 .scroll
1352 .offsets
1353 .insert(node.computed_id.to_string(), offset);
1354 }
1355 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1356 ui_state
1357 .scroll
1358 .pin_prev_max
1359 .insert(node.computed_id.to_string(), max_offset);
1360 }
1361 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1362
1363 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1364 ui_state
1365 .scroll
1366 .virtual_anchors
1367 .insert(node.computed_id.to_string(), anchor);
1368 } else {
1369 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1370 }
1371
1372 ui_state
1373 .scroll
1374 .dyn_height_index
1375 .insert(node.computed_id.to_string(), index);
1376}
1377
1378fn cached_row_height(
1382 ui_state: &UiState,
1383 id: &str,
1384 key: &str,
1385 width_bucket: u32,
1386 estimated_row_height: f32,
1387) -> f32 {
1388 ui_state
1389 .scroll
1390 .measured_row_heights
1391 .get(id)
1392 .and_then(|m| m.get(key))
1393 .and_then(|by_width| by_width.get(&width_bucket))
1394 .copied()
1395 .unwrap_or(estimated_row_height)
1396}
1397
1398#[allow(clippy::too_many_arguments)]
1402fn refresh_index_range(
1403 index: &mut DynHeightIndex,
1404 id: &str,
1405 width_bucket: u32,
1406 start: usize,
1407 end: usize,
1408 row_key: &(dyn Fn(usize) -> String + Send + Sync),
1409 estimated_row_height: f32,
1410 ui_state: &UiState,
1411) {
1412 for idx in start..end {
1413 let key = row_key(idx);
1414 let h = cached_row_height(ui_state, id, &key, width_bucket, estimated_row_height);
1415 index.set_height(idx, h);
1416 }
1417}
1418
1419fn dynamic_anchor_offset_indexed(
1423 node: &El,
1424 index: &DynHeightIndex,
1425 gap: f32,
1426 stored: f32,
1427 ui_state: &UiState,
1428) -> Option<f32> {
1429 let anchor = ui_state.scroll.virtual_anchors.get(&*node.computed_id)?;
1430 let idx = index.index_for_key(&anchor.row_key)?;
1431 let row_h = index.height(idx).max(0.0);
1432 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1433 let scroll_delta = stored - anchor.resolved_offset;
1434 let viewport_y = anchor.viewport_y - scroll_delta;
1435 Some(index.row_top(idx, gap) + row_point - viewport_y)
1436}
1437
1438fn layout_virtual_dynamic(
1439 node: &mut El,
1440 inner: Rect,
1441 count: usize,
1442 estimated_row_height: f32,
1443 fns: DynamicVirtualFns,
1444 ui_state: &mut UiState,
1445) {
1446 let gap = node.gap.max(0.0);
1447 let width_bucket = virtual_width_bucket(inner.w);
1448 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
1449 prune_dynamic_measurements(node, &row_keys, ui_state);
1450
1451 if count == 0 {
1452 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1453 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
1454 debug_assert_eq!(offset, 0.0);
1455 node.children.clear();
1456 return;
1457 }
1458
1459 let mut row_heights = dynamic_row_heights(
1460 node,
1461 &row_keys,
1462 width_bucket,
1463 estimated_row_height,
1464 ui_state,
1465 );
1466
1467 let has_request = node.key.as_deref().is_some_and(|k| {
1473 ui_state.scroll.pending_requests.iter().any(|r| match r {
1474 ScrollRequest::ToRow { list_key, .. } => list_key == k,
1475 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
1476 ScrollRequest::EnsureVisible { .. } => false,
1477 })
1478 });
1479 let mut request_wrote = false;
1480 if has_request {
1481 request_wrote = resolve_scroll_requests(
1482 node,
1483 inner,
1484 count,
1485 |target| {
1486 (
1487 dynamic_row_top(&row_heights, gap, target),
1488 row_heights[target],
1489 )
1490 },
1491 |row_key| row_keys.iter().position(|key| key == row_key),
1492 ui_state,
1493 );
1494 }
1495
1496 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1497 let max_offset = (total_h - inner.h).max(0.0);
1498 let stored = ui_state
1499 .scroll
1500 .offsets
1501 .get(&*node.computed_id)
1502 .copied()
1503 .unwrap_or(0.0);
1504 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1505 let provisional_offset = if pin_active {
1506 match node.pin_policy {
1507 crate::tree::PinPolicy::End => max_offset,
1508 crate::tree::PinPolicy::Start => 0.0,
1509 crate::tree::PinPolicy::None => unreachable!(),
1510 }
1511 } else if request_wrote {
1512 stored
1513 } else {
1514 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1515 .unwrap_or(stored)
1516 }
1517 .clamp(0.0, max_offset);
1518
1519 let (measure_start, _, measure_end) =
1520 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
1521 measure_dynamic_range(
1522 node,
1523 DynamicRangeCtx {
1524 inner,
1525 keys: KeySource::Slice(&row_keys),
1526 width_bucket,
1527 build_row: &fns.build_row,
1528 },
1529 measure_start,
1530 measure_end,
1531 ui_state,
1532 );
1533
1534 row_heights = dynamic_row_heights(
1535 node,
1536 &row_keys,
1537 width_bucket,
1538 estimated_row_height,
1539 ui_state,
1540 );
1541 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1542 let max_offset = (total_h - inner.h).max(0.0);
1543 let stored = ui_state
1544 .scroll
1545 .offsets
1546 .get(&*node.computed_id)
1547 .copied()
1548 .unwrap_or(0.0);
1549 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
1550 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1551 && ui_state
1552 .scroll
1553 .pin_active
1554 .get(&*node.computed_id)
1555 .copied()
1556 .unwrap_or(false);
1557 let mut offset = if pin_active {
1558 pin_resolved
1559 } else if request_wrote {
1560 stored
1561 } else {
1562 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1563 .unwrap_or(stored)
1564 }
1565 .clamp(0.0, max_offset);
1566
1567 ui_state
1568 .scroll
1569 .offsets
1570 .insert(node.computed_id.to_string(), offset);
1571
1572 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
1573 let mut realized_rows = layout_dynamic_range(
1574 node,
1575 DynamicRangeCtx {
1576 inner,
1577 keys: KeySource::Slice(&row_keys),
1578 width_bucket,
1579 build_row: &fns.build_row,
1580 },
1581 offset,
1582 start,
1583 start_y,
1584 end,
1585 ui_state,
1586 );
1587
1588 row_heights = dynamic_row_heights(
1589 node,
1590 &row_keys,
1591 width_bucket,
1592 estimated_row_height,
1593 ui_state,
1594 );
1595 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
1596 let max_offset = (total_h - inner.h).max(0.0);
1597 let corrected_offset = if pin_active {
1598 match node.pin_policy {
1599 crate::tree::PinPolicy::End => max_offset,
1600 crate::tree::PinPolicy::Start => 0.0,
1601 crate::tree::PinPolicy::None => unreachable!(),
1602 }
1603 } else if request_wrote {
1604 offset
1605 } else {
1606 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
1607 .unwrap_or(offset)
1608 }
1609 .clamp(0.0, max_offset);
1610 if (corrected_offset - offset).abs() > 0.01 {
1611 let dy = offset - corrected_offset;
1612 for child in &mut node.children {
1613 shift_subtree_y(child, dy, ui_state);
1614 }
1615 for row in &mut realized_rows {
1616 row.rect.y += dy;
1617 }
1618 offset = corrected_offset;
1619 ui_state
1620 .scroll
1621 .offsets
1622 .insert(node.computed_id.to_string(), offset);
1623 }
1624 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
1625 ui_state
1626 .scroll
1627 .pin_prev_max
1628 .insert(node.computed_id.to_string(), max_offset);
1629 }
1630 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
1631
1632 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
1633 ui_state
1634 .scroll
1635 .virtual_anchors
1636 .insert(node.computed_id.to_string(), anchor);
1637 } else {
1638 ui_state.scroll.virtual_anchors.remove(&*node.computed_id);
1639 }
1640}
1641
1642struct DynamicVirtualFns {
1643 anchor_policy: VirtualAnchorPolicy,
1644 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
1645 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
1646}
1647
1648#[derive(Clone, Copy)]
1649struct DynamicRangeCtx<'a> {
1650 inner: Rect,
1651 keys: KeySource<'a>,
1652 width_bucket: u32,
1653 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
1654}
1655
1656#[derive(Clone, Copy)]
1662enum KeySource<'a> {
1663 Slice(&'a [String]),
1664 Func(&'a (dyn Fn(usize) -> String + Send + Sync)),
1665}
1666
1667impl KeySource<'_> {
1668 fn key(&self, idx: usize) -> String {
1669 match self {
1670 KeySource::Slice(keys) => keys[idx].clone(),
1671 KeySource::Func(f) => f(idx),
1672 }
1673 }
1674}
1675
1676fn virtual_width_bucket(width: f32) -> u32 {
1677 width.max(0.0).round().min(u32::MAX as f32) as u32
1678}
1679
1680fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
1681 let Some(measurements) = ui_state
1682 .scroll
1683 .measured_row_heights
1684 .get_mut(&*node.computed_id)
1685 else {
1686 return;
1687 };
1688 let live_keys = row_keys
1689 .iter()
1690 .map(String::as_str)
1691 .collect::<FxHashSet<_>>();
1692 measurements.retain(|key, widths| {
1693 let live = live_keys.contains(key.as_str());
1694 if live {
1695 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
1696 }
1697 live && !widths.is_empty()
1698 });
1699 if measurements.is_empty() {
1700 ui_state
1701 .scroll
1702 .measured_row_heights
1703 .remove(&*node.computed_id);
1704 }
1705}
1706
1707fn dynamic_row_heights(
1708 node: &El,
1709 row_keys: &[String],
1710 width_bucket: u32,
1711 estimated_row_height: f32,
1712 ui_state: &UiState,
1713) -> Vec<f32> {
1714 let measurements = ui_state.scroll.measured_row_heights.get(&*node.computed_id);
1715 row_keys
1716 .iter()
1717 .map(|key| {
1718 measurements
1719 .and_then(|m| m.get(key))
1720 .and_then(|by_width| by_width.get(&width_bucket))
1721 .copied()
1722 .unwrap_or(estimated_row_height)
1723 })
1724 .collect()
1725}
1726
1727fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1728 row_heights
1729 .iter()
1730 .take(target)
1731 .fold(0.0, |y, h| y + *h + gap)
1732}
1733
1734fn dynamic_visible_range(
1735 row_heights: &[f32],
1736 gap: f32,
1737 offset: f32,
1738 viewport_h: f32,
1739) -> (usize, f32, usize) {
1740 let count = row_heights.len();
1741 let mut start = 0;
1742 let mut y = 0.0_f32;
1743 while start < count {
1744 let h = row_heights[start];
1745 if y + h > offset {
1746 break;
1747 }
1748 y += h + gap;
1749 start += 1;
1750 }
1751
1752 let mut end = start;
1753 let mut cursor = y;
1754 let viewport_bottom = offset + viewport_h;
1755 while end < count && cursor < viewport_bottom {
1756 let h = row_heights[end];
1757 end += 1;
1758 cursor += h + gap;
1759 }
1760 (start, y, end)
1761}
1762
1763fn dynamic_anchor_offset(
1764 node: &El,
1765 row_keys: &[String],
1766 row_heights: &[f32],
1767 gap: f32,
1768 stored: f32,
1769 ui_state: &UiState,
1770) -> Option<f32> {
1771 let anchor = ui_state.scroll.virtual_anchors.get(&*node.computed_id)?;
1772 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1773 anchor.row_index
1774 } else {
1775 row_keys.iter().position(|key| key == &anchor.row_key)?
1776 };
1777 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1778 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1779 let scroll_delta = stored - anchor.resolved_offset;
1780 let viewport_y = anchor.viewport_y - scroll_delta;
1781 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1782}
1783
1784fn measure_dynamic_range(
1785 node: &El,
1786 ctx: DynamicRangeCtx<'_>,
1787 start: usize,
1788 end: usize,
1789 ui_state: &mut UiState,
1790) {
1791 if start >= end {
1792 return;
1793 }
1794 let mut new_measurements = Vec::new();
1795 for idx in start..end {
1796 let key = ctx.keys.key(idx);
1797 let mut child = (ctx.build_row)(idx);
1798 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1799 size_tree(&mut child, Some(ctx.inner.w));
1805 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1806 new_measurements.push((key, actual_h));
1807 }
1808 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1809}
1810
1811fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1812 match child.height {
1813 Size::Fixed(v) => v.max(0.0),
1814 Size::Ch(n) => (n * ch_unit(child)).max(0.0),
1815 Size::Hug => child.measured_size.1.max(0.0),
1818 Size::Aspect(r) => (width * r).max(0.0),
1819 Size::Fill(_) => panic!(
1820 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1821 or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1822 virtualization",
1823 node.computed_id,
1824 ),
1825 }
1826}
1827
1828const MAX_WIDTH_BUCKETS_PER_ROW: usize = 8;
1835
1836fn store_dynamic_measurements(
1837 node: &El,
1838 width_bucket: u32,
1839 measurements: Vec<(String, f32)>,
1840 ui_state: &mut UiState,
1841) {
1842 if measurements.is_empty() {
1843 return;
1844 }
1845 let entry = ui_state
1846 .scroll
1847 .measured_row_heights
1848 .entry(node.computed_id.to_string())
1849 .or_default();
1850 for (row_key, h) in measurements {
1851 let buckets = entry.entry(row_key).or_default();
1852 buckets.insert(width_bucket, h);
1853 if buckets.len() > MAX_WIDTH_BUCKETS_PER_ROW {
1854 let mut widths: Vec<u32> = buckets.keys().copied().collect();
1857 widths.sort_unstable_by_key(|w| (i64::from(*w) - i64::from(width_bucket)).abs());
1858 for w in widths.drain(MAX_WIDTH_BUCKETS_PER_ROW..) {
1859 buckets.remove(&w);
1860 }
1861 }
1862 }
1863}
1864
1865#[derive(Clone, Debug)]
1866struct DynamicRealizedRow {
1867 index: usize,
1868 key: String,
1869 rect: Rect,
1870}
1871
1872fn layout_dynamic_range(
1873 node: &mut El,
1874 ctx: DynamicRangeCtx<'_>,
1875 offset: f32,
1876 start: usize,
1877 start_y: f32,
1878 end: usize,
1879 ui_state: &mut UiState,
1880) -> Vec<DynamicRealizedRow> {
1881 let gap = node.gap.max(0.0);
1882 let mut cursor_y = start_y;
1883 let mut realized = Vec::new();
1884 let mut realized_rows = Vec::new();
1885 let mut new_measurements = Vec::new();
1886
1887 for idx in start..end {
1888 let key = ctx.keys.key(idx);
1889 let mut child = (ctx.build_row)(idx);
1890 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1891 size_tree(&mut child, Some(ctx.inner.w));
1895 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1896 new_measurements.push((key.clone(), actual_h));
1897
1898 let row_y = ctx.inner.y + cursor_y - offset;
1899 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1900 set_rect(&mut child, c_rect, ui_state);
1901 layout_children(&mut child, c_rect, ui_state);
1902
1903 realized_rows.push(DynamicRealizedRow {
1904 index: idx,
1905 key,
1906 rect: c_rect,
1907 });
1908 realized.push(child);
1909 cursor_y += actual_h + gap;
1910 }
1911
1912 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1913 if let (Some(first), Some(last)) = (realized_rows.first(), realized_rows.last()) {
1914 ui_state
1915 .scroll
1916 .visible_ranges
1917 .insert(node.computed_id.to_string(), (first.index, last.index + 1));
1918 }
1919 node.children = realized;
1920 realized_rows
1921}
1922
1923fn choose_dynamic_anchor(
1924 policy: VirtualAnchorPolicy,
1925 inner: Rect,
1926 offset: f32,
1927 rows: &[DynamicRealizedRow],
1928) -> Option<VirtualAnchor> {
1929 let visible = rows
1930 .iter()
1931 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1932 .collect::<Vec<_>>();
1933 if visible.is_empty() {
1934 return None;
1935 }
1936
1937 let chosen = match policy {
1938 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1939 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1940 visible
1941 .iter()
1942 .min_by(|a, b| {
1943 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1944 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1945 ad.total_cmp(&bd)
1946 })
1947 .copied()
1948 .map(|row| {
1949 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1950 (row.clone(), anchor_y)
1951 })
1952 }
1953 VirtualAnchorPolicy::FirstVisible => {
1954 let row = visible
1955 .iter()
1956 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1957 .or_else(|| visible.first())
1958 .copied()?;
1959 let anchor_y = row.rect.y.max(inner.y);
1960 Some((row.clone(), anchor_y))
1961 }
1962 VirtualAnchorPolicy::LastVisible => {
1963 let row = visible
1964 .iter()
1965 .rev()
1966 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1967 .or_else(|| visible.last())
1968 .copied()?;
1969 let anchor_y = row.rect.bottom().min(inner.bottom());
1970 Some((row.clone(), anchor_y))
1971 }
1972 }?;
1973
1974 let (row, anchor_y) = chosen;
1975 let row_h = row.rect.h.max(0.0);
1976 let row_fraction = if row_h > 0.0 {
1977 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1978 } else {
1979 0.0
1980 };
1981 Some(VirtualAnchor {
1982 row_key: row.key.clone(),
1983 row_index: row.index,
1984 row_fraction,
1985 viewport_y: anchor_y - inner.y,
1986 resolved_offset: offset,
1987 })
1988}
1989
1990fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1991 if y < top {
1992 top - y
1993 } else if y > bottom {
1994 y - bottom
1995 } else {
1996 0.0
1997 }
1998}
1999
2000fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
2001 if count == 0 {
2002 0.0
2003 } else {
2004 row_sum + gap * count.saturating_sub(1) as f32
2005 }
2006}
2007
2008fn apply_scroll_offset(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
2016 let inner = node_rect.inset(node.padding);
2017 if node.children.is_empty() {
2018 ui_state
2019 .scroll
2020 .offsets
2021 .insert(node.computed_id.to_string(), 0.0);
2022 ui_state.scroll.scroll_anchors.remove(&*node.computed_id);
2023 ui_state.scroll.metrics.insert(
2024 node.computed_id.to_string(),
2025 crate::state::ScrollMetrics {
2026 viewport_h: inner.h,
2027 content_h: 0.0,
2028 max_offset: 0.0,
2029 },
2030 );
2031 return;
2032 }
2033 let content_bottom = node
2034 .children
2035 .iter()
2036 .map(|c| c.computed_rect.bottom())
2037 .fold(f32::NEG_INFINITY, f32::max);
2038 let content_h = (content_bottom - inner.y).max(0.0);
2039 let max_offset = (content_h - inner.h).max(0.0);
2040
2041 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
2049
2050 let stored = ui_state
2051 .scroll
2052 .offsets
2053 .get(&*node.computed_id)
2054 .copied()
2055 .unwrap_or(0.0);
2056 let stored = resolve_pin(node, stored, max_offset, ui_state);
2057 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
2058 && ui_state
2059 .scroll
2060 .pin_active
2061 .get(&*node.computed_id)
2062 .copied()
2063 .unwrap_or(false);
2064 let stored = if pin_active || request_wrote {
2065 stored
2066 } else {
2067 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
2068 };
2069 let clamped = stored.clamp(0.0, max_offset);
2070 if clamped > 0.0 {
2071 for c in &mut node.children {
2072 shift_subtree_y(c, -clamped, ui_state);
2073 }
2074 }
2075 ui_state
2076 .scroll
2077 .offsets
2078 .insert(node.computed_id.to_string(), clamped);
2079 ui_state.scroll.metrics.insert(
2080 node.computed_id.to_string(),
2081 crate::state::ScrollMetrics {
2082 viewport_h: inner.h,
2083 content_h,
2084 max_offset,
2085 },
2086 );
2087
2088 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
2089
2090 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped) {
2091 ui_state
2092 .scroll
2093 .scroll_anchors
2094 .insert(node.computed_id.to_string(), anchor);
2095 } else {
2096 ui_state.scroll.scroll_anchors.remove(&*node.computed_id);
2097 }
2098}
2099
2100fn apply_viewport_transform(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
2111 use crate::viewport::ViewportView;
2112 let cfg = node
2113 .viewport
2114 .as_deref()
2115 .copied()
2116 .expect("apply_viewport_transform called on a non-viewport node");
2117 let inner = node_rect.inset(node.padding);
2118 let origin = (inner.x, inner.y);
2119 let content = viewport_content_bbox(node);
2120
2121 let mut view = ui_state
2124 .viewport
2125 .views
2126 .get(&*node.computed_id)
2127 .copied()
2128 .unwrap_or_default();
2129 if let Some(key) = node.key.as_deref() {
2130 let mut i = 0;
2131 while i < ui_state.viewport.pending_requests.len() {
2132 if ui_state.viewport.pending_requests[i].key() == key {
2133 let req = ui_state.viewport.pending_requests.remove(i);
2134 match &req {
2138 crate::viewport::ViewportRequest::FitContent { .. }
2139 | crate::viewport::ViewportRequest::ResetView { .. } => {
2140 ui_state.viewport.taken_over.remove(&*node.computed_id);
2141 }
2142 crate::viewport::ViewportRequest::CenterOn { .. } => {
2143 ui_state
2144 .viewport
2145 .taken_over
2146 .insert(node.computed_id.to_string());
2147 }
2148 }
2149 view = apply_viewport_request(req, cfg, inner, origin, content, view);
2150 } else {
2151 i += 1;
2152 }
2153 }
2154 }
2155
2156 match cfg.fit {
2163 crate::viewport::FitPolicy::Manual => {}
2164 crate::viewport::FitPolicy::Contain { padding } => {
2165 if !ui_state.viewport.taken_over.contains(&*node.computed_id) {
2166 view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2167 }
2168 }
2169 crate::viewport::FitPolicy::Lock { padding } => {
2170 ui_state.viewport.taken_over.remove(&*node.computed_id);
2174 view = viewport_fit_view(cfg, inner, origin, content, view, padding);
2175 }
2176 }
2177
2178 view.zoom = view.zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2181 if let Some(c) = content {
2182 clamp_viewport_pan(&mut view, cfg.pan_bounds, inner, origin, c);
2183 }
2184
2185 if view != ViewportView::default() {
2187 transform_viewport_subtree(node, view, origin, ui_state);
2188 }
2189
2190 ui_state
2191 .viewport
2192 .views
2193 .insert(node.computed_id.to_string(), view);
2194 ui_state.viewport.metrics.insert(
2195 node.computed_id.to_string(),
2196 crate::state::ViewportMetrics {
2197 inner,
2198 content,
2199 cfg,
2200 },
2201 );
2202}
2203
2204fn viewport_content_bbox(node: &El) -> Option<Rect> {
2208 let mut acc: Option<Rect> = None;
2209 for c in &node.children {
2210 let r = c.computed_rect;
2211 acc = Some(acc.map_or(r, |a| union_rect(a, r)));
2212 if let Some(bb) = viewport_content_bbox(c) {
2213 acc = Some(acc.map_or(bb, |a| union_rect(a, bb)));
2214 }
2215 }
2216 acc
2217}
2218
2219fn union_rect(a: Rect, b: Rect) -> Rect {
2221 let x = a.x.min(b.x);
2222 let y = a.y.min(b.y);
2223 let r = a.right().max(b.right());
2224 let bot = a.bottom().max(b.bottom());
2225 Rect::new(x, y, r - x, bot - y)
2226}
2227
2228fn transform_viewport_subtree(
2231 node: &mut El,
2232 view: crate::viewport::ViewportView,
2233 origin: (f32, f32),
2234 ui_state: &mut UiState,
2235) {
2236 for c in &mut node.children {
2237 let rect = c.computed_rect;
2238 let (nx, ny) = view.project((rect.x, rect.y), origin);
2239 c.computed_rect = Rect::new(nx, ny, rect.w * view.zoom, rect.h * view.zoom);
2240 if c.key.is_some()
2245 && let Some(r) = ui_state.layout.keyed_rects.get_mut(&c.computed_id)
2246 {
2247 *r = c.computed_rect;
2248 }
2249 transform_viewport_subtree(c, view, origin, ui_state);
2250 }
2251}
2252
2253fn apply_viewport_request(
2257 req: crate::viewport::ViewportRequest,
2258 cfg: crate::viewport::ViewportConfig,
2259 inner: Rect,
2260 origin: (f32, f32),
2261 content: Option<Rect>,
2262 current: crate::viewport::ViewportView,
2263) -> crate::viewport::ViewportView {
2264 use crate::viewport::{ViewportRequest, ViewportView};
2265 match req {
2266 ViewportRequest::ResetView { .. } => ViewportView::default(),
2267 ViewportRequest::CenterOn { point, .. } => {
2268 viewport_center_on(inner, origin, current.zoom, point)
2269 }
2270 ViewportRequest::FitContent { padding, .. } => {
2271 viewport_fit_view(cfg, inner, origin, content, current, padding)
2272 }
2273 }
2274}
2275
2276fn viewport_fit_view(
2282 cfg: crate::viewport::ViewportConfig,
2283 inner: Rect,
2284 origin: (f32, f32),
2285 content: Option<Rect>,
2286 current: crate::viewport::ViewportView,
2287 padding: f32,
2288) -> crate::viewport::ViewportView {
2289 match content {
2290 Some(c) if c.w > 0.0 || c.h > 0.0 => {
2291 let avail_w = (inner.w - 2.0 * padding).max(1.0);
2292 let avail_h = (inner.h - 2.0 * padding).max(1.0);
2293 let mut zoom = f32::INFINITY;
2294 if c.w > 0.0 {
2295 zoom = zoom.min(avail_w / c.w);
2296 }
2297 if c.h > 0.0 {
2298 zoom = zoom.min(avail_h / c.h);
2299 }
2300 if !zoom.is_finite() {
2301 return current;
2302 }
2303 let zoom = zoom.clamp(cfg.min_zoom, cfg.max_zoom);
2304 viewport_center_on(inner, origin, zoom, (c.center_x(), c.center_y()))
2305 }
2306 _ => current,
2307 }
2308}
2309
2310fn viewport_center_on(
2313 inner: Rect,
2314 origin: (f32, f32),
2315 zoom: f32,
2316 point: (f32, f32),
2317) -> crate::viewport::ViewportView {
2318 crate::viewport::ViewportView {
2319 pan: (
2320 inner.center_x() - origin.0 - zoom * (point.0 - origin.0),
2321 inner.center_y() - origin.1 - zoom * (point.1 - origin.1),
2322 ),
2323 zoom,
2324 }
2325}
2326
2327fn clamp_viewport_pan(
2331 view: &mut crate::viewport::ViewportView,
2332 bounds: crate::viewport::PanBounds,
2333 inner: Rect,
2334 origin: (f32, f32),
2335 content: Rect,
2336) {
2337 if matches!(bounds, crate::viewport::PanBounds::Free) {
2338 return;
2339 }
2340 let (lx, ty) = view.project((content.x, content.y), origin);
2341 let w = content.w * view.zoom;
2342 let h = content.h * view.zoom;
2343 view.pan.0 += clamp_axis_delta(bounds, lx, lx + w, inner.x, inner.right(), w, inner.w);
2344 view.pan.1 += clamp_axis_delta(bounds, ty, ty + h, inner.y, inner.bottom(), h, inner.h);
2345}
2346
2347fn clamp_axis_delta(
2351 bounds: crate::viewport::PanBounds,
2352 lo: f32,
2353 hi: f32,
2354 vlo: f32,
2355 vhi: f32,
2356 size: f32,
2357 vsize: f32,
2358) -> f32 {
2359 use crate::viewport::PanBounds;
2360 match bounds {
2361 PanBounds::Free => 0.0,
2363 PanBounds::Center => {
2366 let vc = 0.5 * (vlo + vhi);
2367 if lo > vc {
2368 vc - lo
2369 } else if hi < vc {
2370 vc - hi
2371 } else {
2372 0.0
2373 }
2374 }
2375 PanBounds::Contain => {
2376 if size <= vsize {
2377 if lo < vlo {
2379 vlo - lo
2380 } else if hi > vhi {
2381 vhi - hi
2382 } else {
2383 0.0
2384 }
2385 } else {
2386 if lo > vlo {
2388 vlo - lo
2389 } else if hi < vhi {
2390 vhi - hi
2391 } else {
2392 0.0
2393 }
2394 }
2395 }
2396 }
2397}
2398
2399fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
2400 let anchor = ui_state.scroll.scroll_anchors.get(&*node.computed_id)?;
2401 let rect = &find_descendant_rect(node, anchor.node_id.as_str())?;
2405 if rect.h <= 0.0 {
2406 return None;
2407 }
2408 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
2409 let scroll_delta = stored - anchor.resolved_offset;
2410 let viewport_y = anchor.viewport_y - scroll_delta;
2411 Some(rect.y - inner.y + rect_point - viewport_y)
2412}
2413
2414fn choose_scroll_anchor(node: &El, inner: Rect, offset: f32) -> Option<ScrollAnchor> {
2415 if inner.h <= 0.0 {
2416 return None;
2417 }
2418 let target_y = inner.y + inner.h * 0.25;
2419 let mut best = None;
2420 for child in &node.children {
2421 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, &mut best);
2422 }
2423 let candidate = best?;
2424 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
2425 let rect_fraction = if candidate.rect.h > 0.0 {
2426 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
2427 } else {
2428 0.0
2429 };
2430 Some(ScrollAnchor {
2431 node_id: candidate.node_id,
2432 rect_fraction,
2433 viewport_y: anchor_y - inner.y,
2434 resolved_offset: offset,
2435 })
2436}
2437
2438#[derive(Clone, Debug)]
2439struct ScrollAnchorCandidate {
2440 node_id: String,
2441 rect: Rect,
2442 distance: f32,
2443 depth: usize,
2444}
2445
2446fn choose_scroll_anchor_in_subtree(
2447 node: &El,
2448 inner: Rect,
2449 target_y: f32,
2450 depth: usize,
2451 best: &mut Option<ScrollAnchorCandidate>,
2452) {
2453 let rect = node.computed_rect;
2454 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
2455 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
2456 let candidate = ScrollAnchorCandidate {
2457 node_id: node.computed_id.clone().to_string(),
2458 rect,
2459 distance,
2460 depth,
2461 };
2462 let replace = best.as_ref().is_none_or(|current| {
2463 candidate.distance < current.distance
2464 || (candidate.distance == current.distance && candidate.depth > current.depth)
2465 || (candidate.distance == current.distance
2466 && candidate.depth == current.depth
2467 && candidate.rect.h < current.rect.h)
2468 });
2469 if replace {
2470 *best = Some(candidate);
2471 }
2472 }
2473
2474 if node.scrollable {
2475 return;
2476 }
2477 for child in &node.children {
2478 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, best);
2479 }
2480}
2481
2482const PIN_EPSILON: f32 = 0.5;
2487
2488fn pin_would_be_active(
2496 node: &El,
2497 stored: f32,
2498 _max_offset: f32,
2499 ui_state: &UiState,
2500) -> Option<bool> {
2501 let prev_active = ui_state.scroll.pin_active.get(&*node.computed_id).copied();
2502 match node.pin_policy {
2503 crate::tree::PinPolicy::None => None,
2504 crate::tree::PinPolicy::End => {
2505 let prev_max = ui_state
2506 .scroll
2507 .pin_prev_max
2508 .get(&*node.computed_id)
2509 .copied();
2510 Some(match prev_active {
2511 None => true,
2512 Some(prev) => {
2513 let prev_max = prev_max.unwrap_or(0.0);
2514 if prev && stored < prev_max - PIN_EPSILON {
2515 false
2516 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
2517 true
2518 } else {
2519 prev
2520 }
2521 }
2522 })
2523 }
2524 crate::tree::PinPolicy::Start => Some(match prev_active {
2525 None => true,
2526 Some(prev) => {
2527 if prev && stored > PIN_EPSILON {
2528 false
2529 } else if !prev && stored <= PIN_EPSILON {
2530 true
2531 } else {
2532 prev
2533 }
2534 }
2535 }),
2536 }
2537}
2538
2539fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
2552 if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2553 ui_state.scroll.pin_active.remove(&*node.computed_id);
2554 ui_state.scroll.pin_prev_max.remove(&*node.computed_id);
2555 return stored;
2556 }
2557 let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
2558 ui_state
2559 .scroll
2560 .pin_active
2561 .insert(node.computed_id.to_string(), active);
2562 match node.pin_policy {
2563 crate::tree::PinPolicy::End => {
2564 ui_state
2565 .scroll
2566 .pin_prev_max
2567 .insert(node.computed_id.to_string(), max_offset);
2568 if active { max_offset } else { stored }
2569 }
2570 crate::tree::PinPolicy::Start => {
2571 ui_state.scroll.pin_prev_max.remove(&*node.computed_id);
2575 if active { 0.0 } else { stored }
2576 }
2577 crate::tree::PinPolicy::None => unreachable!(),
2578 }
2579}
2580
2581fn resolve_ensure_visible_for_scroll(
2594 node: &El,
2595 inner: Rect,
2596 content_h: f32,
2597 ui_state: &mut UiState,
2598) -> bool {
2599 if ui_state.scroll.pending_requests.is_empty() {
2600 return false;
2601 }
2602 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
2603 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
2604 let mut wrote = false;
2605 for req in pending {
2606 let ScrollRequest::EnsureVisible {
2607 container_key,
2608 y,
2609 h,
2610 } = &req
2611 else {
2612 remaining.push(req);
2613 continue;
2614 };
2615 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
2616 remaining.push(req);
2621 continue;
2622 };
2623 let inside = node.computed_id == *ancestor_id
2626 || node
2627 .computed_id
2628 .strip_prefix(ancestor_id.as_ref())
2629 .is_some_and(|rest| rest.starts_with('.'));
2630 if !inside {
2631 remaining.push(req);
2632 continue;
2633 }
2634 let current = ui_state
2635 .scroll
2636 .offsets
2637 .get(&*node.computed_id)
2638 .copied()
2639 .unwrap_or(0.0);
2640 let target_top = *y;
2641 let target_bottom = *y + *h;
2642 let viewport_h = inner.h;
2643 let new_offset = if target_top < current {
2650 target_top
2651 } else if target_bottom > current + viewport_h {
2652 target_bottom - viewport_h
2653 } else {
2654 continue;
2659 };
2660 let max = (content_h - viewport_h).max(0.0);
2664 let new_offset = new_offset.clamp(0.0, max);
2665 ui_state
2666 .scroll
2667 .offsets
2668 .insert(node.computed_id.to_string(), new_offset);
2669 wrote = true;
2670 }
2671 ui_state.scroll.pending_requests = remaining;
2672 wrote
2673}
2674
2675fn write_thumb_rect(
2683 node: &El,
2684 inner: Rect,
2685 content_h: f32,
2686 max_offset: f32,
2687 offset: f32,
2688 ui_state: &mut UiState,
2689) {
2690 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
2691 return;
2692 }
2693 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
2694 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
2695 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
2696 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
2697 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
2698 let track_remaining = (inner.h - thumb_h).max(0.0);
2699 let thumb_y = inner.y + track_remaining * (offset / max_offset);
2700 let edge = if node.scrollbar_gutter {
2705 inner.right() + crate::tokens::SCROLLBAR_GUTTER
2706 } else {
2707 inner.right()
2708 };
2709 let thumb_x = edge - thumb_w - track_inset;
2710 let track_x = edge - track_w - track_inset;
2711 ui_state.scroll.thumb_rects.insert(
2712 node.computed_id.to_string(),
2713 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
2714 );
2715 ui_state.scroll.thumb_tracks.insert(
2716 node.computed_id.to_string(),
2717 Rect::new(track_x, inner.y, track_w, inner.h),
2718 );
2719}
2720
2721fn shift_subtree_y(node: &mut El, dy: f32, ui_state: &mut UiState) {
2722 node.computed_rect.y += dy;
2723 if node.key.is_some()
2724 && let Some(rect) = ui_state.layout.keyed_rects.get_mut(&node.computed_id)
2725 {
2726 rect.y += dy;
2727 }
2728 if node.scrollbar {
2731 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&*node.computed_id) {
2732 thumb.y += dy;
2733 }
2734 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&*node.computed_id) {
2735 track.y += dy;
2736 }
2737 }
2738 for c in &mut node.children {
2739 shift_subtree_y(c, dy, ui_state);
2740 }
2741}
2742
2743#[derive(Default)]
2750struct AxisScratch {
2751 main_sizes: Vec<f32>,
2752 fill_weights: Vec<Option<f32>>,
2753 row_slots: Vec<Option<(f32, f32)>>,
2754}
2755
2756impl AxisScratch {
2757 fn take() -> Self {
2758 AXIS_SCRATCH_POOL
2759 .with_borrow_mut(|pool| pool.pop())
2760 .unwrap_or_default()
2761 }
2762
2763 fn release(mut self) {
2764 self.main_sizes.clear();
2765 self.fill_weights.clear();
2766 self.row_slots.clear();
2767 AXIS_SCRATCH_POOL.with_borrow_mut(|pool| {
2768 if pool.len() < 256 {
2770 pool.push(self);
2771 }
2772 });
2773 }
2774}
2775
2776thread_local! {
2777 static AXIS_SCRATCH_POOL: std::cell::RefCell<Vec<AxisScratch>> =
2778 const { std::cell::RefCell::new(Vec::new()) };
2779}
2780
2781fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
2782 let inner = node_rect.inset(node.padding);
2783 let n = node.children.len();
2784 if n == 0 {
2785 return;
2786 }
2787 let mut scratch = AxisScratch::take();
2788
2789 let total_gap = node.gap * n.saturating_sub(1) as f32;
2790 let main_extent = if vertical { inner.h } else { inner.w };
2791 let cross_extent = if vertical { inner.w } else { inner.h };
2792
2793 let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
2802 let main_intent = if vertical { c.height } else { c.width };
2803 if let Size::Aspect(r) = main_intent {
2804 let cross_intent = if vertical { c.width } else { c.height };
2805 if !matches!(cross_intent, Size::Aspect(_)) {
2806 let cross_intrinsic = if vertical { iw } else { ih };
2807 let cross_size = match cross_intent {
2808 Size::Fixed(v) => v,
2809 Size::Ch(n) => n * ch_unit(c),
2810 Size::Hug | Size::Fill(_) => match node.align {
2811 Align::Stretch => cross_extent,
2812 Align::Start | Align::Center | Align::End => cross_intrinsic,
2813 },
2814 Size::Aspect(_) => unreachable!(),
2815 };
2816 let cross_size = if vertical {
2817 clamp_w(c, cross_size)
2818 } else {
2819 clamp_h(c, cross_size)
2820 };
2821 let main = cross_size * r.max(0.0);
2822 let clamped = if vertical {
2823 clamp_h(c, main)
2824 } else {
2825 clamp_w(c, main)
2826 };
2827 return MainSize::Resolved(clamped);
2828 }
2829 }
2830 main_size_of(c, iw, ih, vertical)
2831 };
2832
2833 let AxisScratch {
2844 main_sizes,
2845 fill_weights,
2846 ..
2847 } = &mut scratch;
2848 let mut consumed = 0.0;
2849 for c in node.children.iter() {
2850 let (iw, ih) = c.measured_size;
2853 match resolve_main(c, iw, ih) {
2854 MainSize::Resolved(v) => {
2855 consumed += v;
2856 main_sizes.push(v);
2857 fill_weights.push(None);
2858 }
2859 MainSize::Fill(w) => {
2860 main_sizes.push(0.0);
2861 fill_weights.push(Some(w.max(0.001)));
2862 }
2863 }
2864 }
2865 let mut remaining = (main_extent - consumed - total_gap).max(0.0);
2866 loop {
2867 let flexible_weight: f32 = fill_weights.iter().flatten().sum();
2868 if flexible_weight == 0.0 {
2869 break;
2870 }
2871 let mut frozen_any = false;
2872 let mut newly_frozen = 0.0;
2873 for (i, c) in node.children.iter().enumerate() {
2874 let Some(w) = fill_weights[i] else { continue };
2875 let raw = remaining * w / flexible_weight;
2876 let clamped = if vertical {
2877 clamp_h(c, raw)
2878 } else {
2879 clamp_w(c, raw)
2880 };
2881 main_sizes[i] = clamped;
2882 if clamped != raw {
2883 fill_weights[i] = None;
2884 frozen_any = true;
2885 newly_frozen += clamped;
2886 }
2887 }
2888 if !frozen_any {
2889 remaining = 0.0;
2891 break;
2892 }
2893 remaining = (remaining - newly_frozen).max(0.0);
2894 }
2895
2896 let free_after_used = remaining;
2900 let mut cursor = match node.justify {
2901 Justify::Start => 0.0,
2902 Justify::Center => free_after_used * 0.5,
2903 Justify::End => free_after_used,
2904 Justify::SpaceBetween => 0.0,
2905 };
2906 let between_extra = if matches!(node.justify, Justify::SpaceBetween) && n > 1 {
2907 free_after_used / (n - 1) as f32
2908 } else {
2909 0.0
2910 };
2911 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
2912
2913 crate::profile_span!("layout::axis::place");
2914 for (i, c) in node.children.iter_mut().enumerate() {
2915 let (iw, ih) = c.measured_size;
2916 let main_size = main_sizes[i];
2917
2918 let cross_intent = if vertical { c.width } else { c.height };
2919 let cross_intrinsic = if vertical { iw } else { ih };
2920 let cross_size = match cross_intent {
2932 Size::Fixed(v) => v,
2933 Size::Ch(n) => n * ch_unit(c),
2934 Size::Aspect(r) => main_size * r,
2935 Size::Hug | Size::Fill(_) => match node.align {
2936 Align::Stretch => cross_extent,
2937 Align::Start | Align::Center | Align::End => cross_intrinsic,
2938 },
2939 };
2940 let cross_size = if vertical {
2941 clamp_w(c, cross_size)
2942 } else {
2943 clamp_h(c, cross_size)
2944 };
2945
2946 let cross_off = match node.align {
2947 Align::Start | Align::Stretch => 0.0,
2948 Align::Center => (cross_extent - cross_size) * 0.5,
2949 Align::End => cross_extent - cross_size,
2950 };
2951
2952 let c_rect = if vertical {
2953 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
2954 } else {
2955 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
2956 };
2957 set_rect(c, c_rect, ui_state);
2958 if can_prune_scroll_child(c, c_rect, scroll_visible) {
2959 let nodes = zero_descendant_rects(c, c_rect, ui_state);
2960 record_pruned_subtree(nodes);
2961 } else {
2962 resize_if_width_diverged(c, c_rect.w);
2963 layout_children(c, c_rect, ui_state);
2964 }
2965
2966 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
2967 }
2968 scratch.release();
2969}
2970
2971const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
2972
2973fn scroll_visible_content_rect(
2974 node: &El,
2975 inner: Rect,
2976 vertical: bool,
2977 ui_state: &UiState,
2978) -> Option<Rect> {
2979 if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
2980 return None;
2981 }
2982 let offset = ui_state
2983 .scroll
2984 .offsets
2985 .get(&*node.computed_id)
2986 .copied()
2987 .unwrap_or(0.0)
2988 .max(0.0);
2989 Some(Rect::new(
2990 inner.x,
2991 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
2992 inner.w,
2993 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
2994 ))
2995}
2996
2997fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
2998 let Some(visible) = visible else {
2999 return false;
3000 };
3001 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
3002}
3003
3004pub(crate) fn subtree_is_layout_confined(node: &El) -> bool {
3007 if node.translate != (0.0, 0.0)
3008 || node.scale != 1.0
3009 || node.shadow > 0.0
3010 || node.paint_overflow != Sides::zero()
3011 || node.hit_overflow != Sides::zero()
3012 || node.layout_override.is_some()
3013 || node.virtual_items.is_some()
3014 {
3015 return false;
3016 }
3017 node.children.iter().all(subtree_is_layout_confined)
3018}
3019
3020fn zero_descendant_rects(node: &mut El, rect: Rect, ui_state: &mut UiState) -> u64 {
3021 let mut count = 0;
3022 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
3023 for child in &mut node.children {
3024 set_rect(child, zero, ui_state);
3025 count += 1 + zero_descendant_rects(child, zero, ui_state);
3026 }
3027 count
3028}
3029
3030fn record_pruned_subtree(nodes: u64) {
3031 PRUNE_STATS.with(|stats| {
3032 let mut stats = stats.borrow_mut();
3033 stats.subtrees += 1;
3034 stats.nodes += nodes;
3035 });
3036}
3037
3038enum MainSize {
3039 Resolved(f32),
3040 Fill(f32),
3041}
3042
3043fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
3044 let s = if vertical { c.height } else { c.width };
3045 let intr = if vertical { ih } else { iw };
3046 let clamp = |v: f32| {
3047 if vertical {
3048 clamp_h(c, v)
3049 } else {
3050 clamp_w(c, v)
3051 }
3052 };
3053 match s {
3054 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
3055 Size::Ch(n) => MainSize::Resolved(clamp(n * ch_unit(c))),
3056 Size::Hug => MainSize::Resolved(clamp(intr)),
3057 Size::Fill(w) => MainSize::Fill(w),
3058 Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
3065 }
3066}
3067
3068fn overlay_rect(
3077 c: &El,
3078 parent: Rect,
3079 align: Align,
3080 justify: Justify,
3081 clamp_to_parent: bool,
3082) -> Rect {
3083 let hug_w = |iw: f32| {
3086 if clamp_to_parent {
3087 iw.min(parent.w)
3088 } else {
3089 iw
3090 }
3091 };
3092 let hug_h = |ih: f32| {
3093 if clamp_to_parent {
3094 ih.min(parent.h)
3095 } else {
3096 ih
3097 }
3098 };
3099 let (iw, ih) = c.measured_size;
3104 let (w, h) = match (c.width, c.height) {
3109 (Size::Aspect(_), Size::Aspect(_)) => (hug_w(iw), hug_h(ih)),
3110 (Size::Aspect(r), _) => {
3111 let h = match c.height {
3112 Size::Fixed(v) => v,
3113 Size::Ch(n) => n * ch_unit(c),
3114 Size::Hug => hug_h(ih),
3115 Size::Fill(_) => parent.h,
3116 Size::Aspect(_) => unreachable!(),
3117 };
3118 (h * r, h)
3119 }
3120 (_, Size::Aspect(r)) => {
3121 let w = match c.width {
3122 Size::Fixed(v) => v,
3123 Size::Ch(n) => n * ch_unit(c),
3124 Size::Hug => hug_w(iw),
3125 Size::Fill(_) => parent.w,
3126 Size::Aspect(_) => unreachable!(),
3127 };
3128 (w, w * r)
3129 }
3130 _ => {
3131 let w = match c.width {
3132 Size::Fixed(v) => v,
3133 Size::Ch(n) => n * ch_unit(c),
3134 Size::Hug => hug_w(iw),
3135 Size::Fill(_) => parent.w,
3136 Size::Aspect(_) => unreachable!(),
3137 };
3138 let h = match c.height {
3139 Size::Fixed(v) => v,
3140 Size::Ch(n) => n * ch_unit(c),
3141 Size::Hug => hug_h(ih),
3142 Size::Fill(_) => parent.h,
3143 Size::Aspect(_) => unreachable!(),
3144 };
3145 (w, h)
3146 }
3147 };
3148 let w = clamp_w(c, w);
3149 let h = clamp_h(c, h);
3150 let x = match align {
3151 Align::Start | Align::Stretch => parent.x,
3152 Align::Center => parent.x + (parent.w - w) * 0.5,
3153 Align::End => parent.right() - w,
3154 };
3155 let y = match justify {
3156 Justify::Start | Justify::SpaceBetween => parent.y,
3157 Justify::Center => parent.y + (parent.h - h) * 0.5,
3158 Justify::End => parent.bottom() - h,
3159 };
3160 Rect::new(x, y, w, h)
3161}
3162
3163pub fn intrinsic(c: &El) -> (f32, f32) {
3168 intrinsic_constrained(c, None)
3169}
3170
3171fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
3172 let available_width = match c.width {
3177 Size::Fixed(v) => Some(v),
3178 _ => available_width,
3179 };
3180 apply_aspect(
3181 c,
3182 available_width,
3183 intrinsic_constrained_uncached(c, available_width),
3184 )
3185}
3186
3187fn size_tree(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3204 SIZING_VISITS.with(|c| c.set(c.get() + 1));
3205 let available_width = match node.width {
3212 Size::Fixed(v) => Some(v),
3213 Size::Ch(n) => Some(n * ch_unit(node)),
3214 _ => available_width,
3215 };
3216 let inner = size_tree_inner(node, available_width);
3217 let size = apply_aspect(node, available_width, inner);
3218 node.measured_size = size;
3219 node.sized_at_width = available_width.unwrap_or(size.0);
3222 node.width_sensitive = (node.text.is_some() && matches!(node.text_wrap, TextWrap::Wrap))
3229 || matches!(node.kind, Kind::Inlines)
3230 || node.layout_override.is_some()
3231 || node.virtual_items.is_some()
3232 || matches!(node.width, Size::Aspect(_))
3233 || matches!(node.height, Size::Aspect(_))
3234 || node.children.iter().any(|c| c.width_sensitive);
3235 size
3236}
3237
3238#[inline]
3247fn resize_if_width_diverged(c: &mut El, final_w: f32) {
3248 if c.width_sensitive && !c.children.is_empty() && (final_w - c.sized_at_width).abs() > 0.5 {
3249 size_tree(c, Some(final_w));
3250 }
3251}
3252
3253fn size_tree_inner(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3254 if let Some(size) = leaf_intrinsic(node, available_width) {
3255 if node.layout_override.is_some() {
3256 for ch in &mut node.children {
3261 size_tree(ch, None);
3262 }
3263 } else if !node.children.is_empty()
3264 && node.virtual_items.is_none()
3265 && !matches!(node.kind, Kind::Inlines)
3266 {
3267 size_tree_children(node, Some(size.0));
3275 }
3276 return size;
3277 }
3278 size_tree_children(node, available_width)
3279}
3280
3281fn size_tree_children(node: &mut El, available_width: Option<f32>) -> (f32, f32) {
3285 match node.axis {
3286 Axis::Overlay => {
3287 let child_available =
3288 available_width.map(|w| (w - node.padding.left - node.padding.right).max(0.0));
3289 let mut w: f32 = 0.0;
3290 let mut h: f32 = 0.0;
3291 for ch in &mut node.children {
3292 let ca = if matches!(ch.width, Size::Aspect(_)) {
3298 None
3299 } else {
3300 child_available
3301 };
3302 let (cw, chh) = size_tree(ch, ca);
3303 w = w.max(cw);
3304 h = h.max(chh);
3305 }
3306 apply_min(
3307 node,
3308 w + node.padding.left + node.padding.right,
3309 h + node.padding.top + node.padding.bottom,
3310 )
3311 }
3312 Axis::Column => {
3313 let mut w: f32 = 0.0;
3314 let mut h: f32 = node.padding.top + node.padding.bottom;
3315 let n = node.children.len();
3316 let child_available =
3317 available_width.map(|w| (w - node.padding.left - node.padding.right).max(0.0));
3318 for (i, ch) in node.children.iter_mut().enumerate() {
3319 let (cw, chh) = size_tree(ch, child_available);
3320 w = w.max(cw);
3321 h += chh;
3322 if i + 1 < n {
3323 h += node.gap;
3324 }
3325 }
3326 apply_min(node, w + node.padding.left + node.padding.right, h)
3327 }
3328 Axis::Row => {
3329 let n = node.children.len();
3339 let total_gap = node.gap * n.saturating_sub(1) as f32;
3340 let inner_available = available_width
3341 .map(|w| (w - node.padding.left - node.padding.right - total_gap).max(0.0));
3342
3343 let mut scratch = AxisScratch::take();
3344 let mut consumed: f32 = 0.0;
3345 let mut fill_weight_total: f32 = 0.0;
3346 for ch in &mut node.children {
3347 match ch.width {
3348 Size::Fill(w) => {
3349 fill_weight_total += w.max(0.001);
3350 scratch.row_slots.push(None);
3351 }
3352 _ => {
3353 let (cw, chh) = size_tree(ch, None);
3354 consumed += cw;
3355 scratch.row_slots.push(Some((cw, chh)));
3356 }
3357 }
3358 }
3359
3360 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3361 let mut w_total: f32 = node.padding.left + node.padding.right;
3362 let mut h_max: f32 = 0.0;
3363 for (i, ch) in node.children.iter_mut().enumerate() {
3364 let (cw, chh) = match scratch.row_slots[i] {
3365 Some(rc) => rc,
3366 None => match (fill_remaining, fill_weight_total > 0.0) {
3367 (Some(av), true) => {
3368 let weight = match ch.width {
3369 Size::Fill(w) => w.max(0.001),
3370 _ => 1.0,
3371 };
3372 size_tree(ch, Some(av * weight / fill_weight_total))
3373 }
3374 _ => size_tree(ch, None),
3375 },
3376 };
3377 w_total += cw;
3378 if i + 1 < n {
3379 w_total += node.gap;
3380 }
3381 h_max = h_max.max(chh);
3382 }
3383 scratch.release();
3384 apply_min(
3385 node,
3386 w_total,
3387 h_max + node.padding.top + node.padding.bottom,
3388 )
3389 }
3390 }
3391}
3392
3393fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
3408 match (c.width, c.height) {
3409 (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
3410 (Size::Aspect(r), _) => {
3411 (clamp_w(c, ih * r.max(0.0)), ih)
3416 }
3417 (_, Size::Aspect(r)) => {
3418 let raw_basis = match c.width {
3419 Size::Fixed(v) => v,
3420 Size::Ch(n) => n * ch_unit(c),
3421 Size::Fill(_) => available_width.unwrap_or(iw),
3422 Size::Hug | Size::Aspect(_) => iw,
3423 };
3424 let basis = clamp_w(c, raw_basis);
3428 (iw, clamp_h(c, basis * r.max(0.0)))
3429 }
3430 _ => (iw, ih),
3431 }
3432}
3433
3434fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
3435 if let Some(size) = leaf_intrinsic(c, available_width) {
3436 return size;
3437 }
3438 container_intrinsic(c, available_width)
3439}
3440
3441fn leaf_intrinsic(c: &El, available_width: Option<f32>) -> Option<(f32, f32)> {
3448 if c.layout_override.is_some() {
3449 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3454 panic!(
3455 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
3456 Size::Hug is not supported for custom layouts",
3457 c.computed_id,
3458 );
3459 }
3460 return Some(apply_min(c, 0.0, 0.0));
3461 }
3462 if c.virtual_items.is_some() {
3463 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
3468 panic!(
3469 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
3470 Size::Hug would defeat virtualization",
3471 c.computed_id,
3472 );
3473 }
3474 return Some(apply_min(c, 0.0, 0.0));
3475 }
3476 if matches!(c.kind, Kind::Inlines) {
3477 return Some(inline_paragraph_intrinsic(c, available_width));
3478 }
3479 if matches!(c.kind, Kind::HardBreak) {
3480 return Some(apply_min(c, 0.0, 0.0));
3484 }
3485 if matches!(c.kind, Kind::Math) {
3486 if let Some(expr) = &c.math {
3487 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
3488 return Some(apply_min(
3489 c,
3490 layout.width + c.padding.left + c.padding.right,
3491 layout.height() + c.padding.top + c.padding.bottom,
3492 ));
3493 }
3494 return Some(apply_min(c, 0.0, 0.0));
3495 }
3496 if c.icon.is_some() {
3497 return Some(apply_min(
3498 c,
3499 c.font_size + c.padding.left + c.padding.right,
3500 c.font_size + c.padding.top + c.padding.bottom,
3501 ));
3502 }
3503 if let Some(img) = &c.image {
3504 let w = img.width() as f32 + c.padding.left + c.padding.right;
3508 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
3509 return Some(apply_min(c, w, h));
3510 }
3511 if let Some(text) = &c.text {
3512 let content_available = match c.text_wrap {
3513 TextWrap::NoWrap => None,
3514 TextWrap::Wrap => available_width
3515 .or(match c.width {
3516 Size::Fixed(v) => Some(v),
3517 Size::Ch(n) => Some(n * ch_unit(c)),
3518 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3522 })
3523 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3524 };
3525 let display = display_text_for_measure(c, text, content_available);
3526 let layout = text_metrics::layout_text_with_line_height_and_family(
3527 &display,
3528 c.font_size,
3529 c.line_height,
3530 c.font_family,
3531 c.font_weight,
3532 c.font_mono,
3533 c.text_tabular_numerals,
3534 c.text_wrap,
3535 content_available,
3536 );
3537 let w = match (content_available, c.width) {
3538 (Some(available), Size::Hug | Size::Aspect(_)) => {
3539 let unwrapped = text_metrics::layout_text_with_family(
3540 text,
3541 c.font_size,
3542 c.font_family,
3543 c.font_weight,
3544 c.font_mono,
3545 c.text_tabular_numerals,
3546 TextWrap::NoWrap,
3547 None,
3548 );
3549 unwrapped.width.min(available) + c.padding.left + c.padding.right
3550 }
3551 (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3552 available + c.padding.left + c.padding.right
3553 }
3554 (None, _) => layout.width + c.padding.left + c.padding.right,
3555 };
3556 let h = layout.height + c.padding.top + c.padding.bottom;
3557 return Some(apply_min(c, w, h));
3558 }
3559 None
3560}
3561
3562fn container_intrinsic(c: &El, available_width: Option<f32>) -> (f32, f32) {
3566 match c.axis {
3567 Axis::Overlay => {
3568 let mut w: f32 = 0.0;
3569 let mut h: f32 = 0.0;
3570 for ch in &c.children {
3571 let child_available =
3572 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3573 let (cw, chh) = intrinsic_constrained(ch, child_available);
3574 w = w.max(cw);
3575 h = h.max(chh);
3576 }
3577 apply_min(
3578 c,
3579 w + c.padding.left + c.padding.right,
3580 h + c.padding.top + c.padding.bottom,
3581 )
3582 }
3583 Axis::Column => {
3584 let mut w: f32 = 0.0;
3585 let mut h: f32 = c.padding.top + c.padding.bottom;
3586 let n = c.children.len();
3587 let child_available =
3588 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
3589 for (i, ch) in c.children.iter().enumerate() {
3590 let (cw, chh) = intrinsic_constrained(ch, child_available);
3591 w = w.max(cw);
3592 h += chh;
3593 if i + 1 < n {
3594 h += c.gap;
3595 }
3596 }
3597 apply_min(c, w + c.padding.left + c.padding.right, h)
3598 }
3599 Axis::Row => {
3600 let n = c.children.len();
3610 let total_gap = c.gap * n.saturating_sub(1) as f32;
3611 let inner_available = available_width
3612 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
3613
3614 let mut consumed: f32 = 0.0;
3620 let mut fill_weight_total: f32 = 0.0;
3621 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
3622 for ch in &c.children {
3623 match ch.width {
3624 Size::Fill(w) => {
3625 fill_weight_total += w.max(0.001);
3626 sizes.push(None);
3627 }
3628 _ => {
3629 let (cw, chh) = intrinsic(ch);
3630 consumed += cw;
3631 sizes.push(Some((cw, chh)));
3632 }
3633 }
3634 }
3635
3636 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
3644 let mut w_total: f32 = c.padding.left + c.padding.right;
3645 let mut h_max: f32 = 0.0;
3646 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
3647 let (cw, chh) = match slot {
3648 Some(rc) => rc,
3649 None => match (fill_remaining, fill_weight_total > 0.0) {
3650 (Some(av), true) => {
3651 let weight = match ch.width {
3652 Size::Fill(w) => w.max(0.001),
3653 _ => 1.0,
3654 };
3655 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
3656 }
3657 _ => intrinsic(ch),
3658 },
3659 };
3660 w_total += cw;
3661 if i + 1 < n {
3662 w_total += c.gap;
3663 }
3664 h_max = h_max.max(chh);
3665 }
3666 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
3667 }
3668 }
3669}
3670
3671pub(crate) fn text_layout(
3672 c: &El,
3673 available_width: Option<f32>,
3674) -> Option<text_metrics::TextLayout> {
3675 let text = c.text.as_ref()?;
3676 let content_available = match c.text_wrap {
3677 TextWrap::NoWrap => None,
3678 TextWrap::Wrap => available_width
3679 .or(match c.width {
3680 Size::Fixed(v) => Some(v),
3681 Size::Ch(n) => Some(n * ch_unit(c)),
3682 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3683 })
3684 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
3685 };
3686 let display = display_text_for_measure(c, text, content_available);
3687 Some(text_metrics::layout_text_with_line_height_and_family(
3688 &display,
3689 c.font_size,
3690 c.line_height,
3691 c.font_family,
3692 c.font_weight,
3693 c.font_mono,
3694 c.text_tabular_numerals,
3695 c.text_wrap,
3696 content_available,
3697 ))
3698}
3699
3700fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
3701 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
3702 (c.text_wrap, c.text_max_lines, available_width)
3703 {
3704 text_metrics::clamp_text_to_lines_with_family(
3705 text,
3706 c.font_size,
3707 c.font_family,
3708 c.font_weight,
3709 c.font_mono,
3710 width,
3711 max_lines.get() as usize,
3712 )
3713 } else {
3714 text.to_string()
3715 }
3716}
3717
3718fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
3719 if let Size::Fixed(v) = c.width {
3720 w = v;
3721 }
3722 if let Size::Fixed(v) = c.height {
3723 h = v;
3724 }
3725 (clamp_w(c, w), clamp_h(c, h))
3726}
3727
3728pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
3734 if let Some(max_w) = c.max_width {
3735 w = w.min(max_w);
3736 }
3737 if let Some(min_w) = c.min_width {
3738 w = w.max(min_w);
3739 }
3740 w.max(0.0)
3741}
3742
3743pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
3745 if let Some(max_h) = c.max_height {
3746 h = h.min(max_h);
3747 }
3748 if let Some(min_h) = c.min_height {
3749 h = h.max(min_h);
3750 }
3751 h.max(0.0)
3752}
3753
3754fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3767 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
3768 return inline_mixed_intrinsic(node, available_width);
3769 }
3770 let concat = concat_inline_text(&node.children);
3771 let size = inline_paragraph_size(node);
3772 let line_height = inline_paragraph_line_height(node);
3773 let content_available = match node.text_wrap {
3774 TextWrap::NoWrap => None,
3775 TextWrap::Wrap => available_width
3776 .or(match node.width {
3777 Size::Fixed(v) => Some(v),
3778 Size::Ch(n) => Some(n * ch_unit(node)),
3779 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3780 })
3781 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
3782 };
3783 let layout = text_metrics::layout_text_with_line_height_and_family(
3784 &concat,
3785 size,
3786 line_height,
3787 node.font_family,
3788 FontWeight::Regular,
3789 false,
3790 false,
3791 node.text_wrap,
3792 content_available,
3793 );
3794 let w = match (content_available, node.width) {
3795 (Some(available), Size::Hug | Size::Aspect(_)) => {
3796 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
3797 &concat,
3798 size,
3799 line_height,
3800 node.font_family,
3801 FontWeight::Regular,
3802 false,
3803 false,
3804 TextWrap::NoWrap,
3805 None,
3806 );
3807 unwrapped.width.min(available) + node.padding.left + node.padding.right
3808 }
3809 (Some(available), Size::Fixed(_) | Size::Fill(_) | Size::Ch(_)) => {
3810 available + node.padding.left + node.padding.right
3811 }
3812 (None, _) => layout.width + node.padding.left + node.padding.right,
3813 };
3814 let h = layout.height + node.padding.top + node.padding.bottom;
3815 apply_min(node, w, h)
3816}
3817
3818fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
3819 let wrap_width = match node.text_wrap {
3820 TextWrap::Wrap => available_width.or(match node.width {
3821 Size::Fixed(v) => Some(v),
3822 Size::Ch(n) => Some(n * ch_unit(node)),
3823 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
3824 }),
3825 TextWrap::NoWrap => None,
3826 }
3827 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
3828
3829 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
3830 node.text_wrap,
3831 wrap_width,
3832 node.font_size * 0.82,
3833 node.font_size * 0.22,
3834 node.line_height,
3835 );
3836
3837 for child in &node.children {
3838 match child.kind {
3839 Kind::HardBreak => {
3840 breaker.finish_line();
3841 continue;
3842 }
3843 Kind::Text => {
3844 let text = child.text.as_deref().unwrap_or("");
3845 for chunk in inline_text_chunks(text) {
3846 let is_space = chunk.chars().all(char::is_whitespace);
3847 if breaker.skips_leading_space(is_space) {
3848 continue;
3849 }
3850 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
3851 if breaker.wraps_before(is_space, w) {
3852 breaker.finish_line();
3853 }
3854 if breaker.skips_overflowing_space(is_space, w) {
3855 continue;
3856 }
3857 breaker.push(w, ascent, descent);
3858 }
3859 continue;
3860 }
3861 _ => {}
3862 }
3863 let (w, ascent, descent) = inline_child_metrics(child);
3864 if breaker.wraps_before(false, w) {
3865 breaker.finish_line();
3866 }
3867 breaker.push(w, ascent, descent);
3868 }
3869 let measurement = breaker.finish();
3870 let w = measurement.width + node.padding.left + node.padding.right;
3871 let h = measurement.height + node.padding.top + node.padding.bottom;
3872 apply_min(node, w, h)
3873}
3874
3875fn inline_text_chunks(text: &str) -> Vec<&str> {
3876 let mut chunks = Vec::new();
3877 let mut start = 0;
3878 let mut last_space = None;
3879 for (i, ch) in text.char_indices() {
3880 let is_space = ch.is_whitespace();
3881 match last_space {
3882 None => last_space = Some(is_space),
3883 Some(prev) if prev != is_space => {
3884 chunks.push(&text[start..i]);
3885 start = i;
3886 last_space = Some(is_space);
3887 }
3888 _ => {}
3889 }
3890 }
3891 if start < text.len() {
3892 chunks.push(&text[start..]);
3893 }
3894 chunks
3895}
3896
3897fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
3898 let layout = text_metrics::layout_text_with_line_height_and_family(
3899 text,
3900 child.font_size,
3901 child.line_height,
3902 child.font_family,
3903 child.font_weight,
3904 child.font_mono,
3905 child.text_tabular_numerals,
3906 TextWrap::NoWrap,
3907 None,
3908 );
3909 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
3910}
3911
3912fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
3913 match child.kind {
3914 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
3915 Kind::Math => {
3916 if let Some(expr) = &child.math {
3917 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
3918 (layout.width, layout.ascent, layout.descent)
3919 } else {
3920 (0.0, 0.0, 0.0)
3921 }
3922 }
3923 _ => (0.0, 0.0, 0.0),
3924 }
3925}
3926
3927fn concat_inline_text(children: &[El]) -> String {
3934 let mut s = String::new();
3935 for c in children {
3936 match c.kind {
3937 Kind::Text => {
3938 if let Some(t) = &c.text {
3939 s.push_str(t);
3940 }
3941 }
3942 Kind::HardBreak => s.push('\n'),
3943 _ => {}
3944 }
3945 }
3946 s
3947}
3948
3949fn inline_paragraph_size(node: &El) -> f32 {
3953 let mut size: f32 = node.font_size;
3954 for c in &node.children {
3955 if matches!(c.kind, Kind::Text) {
3956 size = size.max(c.font_size);
3957 }
3958 }
3959 size
3960}
3961
3962fn inline_paragraph_line_height(node: &El) -> f32 {
3963 let mut line_height: f32 = node.line_height;
3964 let mut max_size: f32 = node.font_size;
3965 for c in &node.children {
3966 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
3967 max_size = c.font_size;
3968 line_height = c.line_height;
3969 }
3970 }
3971 line_height
3972}
3973
3974#[cfg(test)]
3975mod tests {
3976 use super::*;
3977 use crate::state::UiState;
3978
3979 #[test]
3984 fn duplicate_sibling_keys_flagged_once() {
3985 let mut root = column([
3986 crate::widgets::text::text("a").key("dup"),
3987 crate::widgets::text::text("b").key("dup"),
3988 ]);
3989 let mut state = UiState::new();
3990 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
3991
3992 assert_eq!(root.children[0].computed_id, root.children[1].computed_id);
3994 let id = root.children[0].computed_id.clone();
3995 assert!(state.layout.warned_duplicate_ids.contains(&*id));
3997
3998 let n_before = state.layout.warned_duplicate_ids.len();
4000 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4001 assert_eq!(state.layout.warned_duplicate_ids.len(), n_before);
4002 }
4003
4004 #[test]
4006 fn distinct_sibling_keys_not_flagged() {
4007 let mut root = column([
4008 crate::widgets::text::text("a").key("one"),
4009 crate::widgets::text::text("b").key("two"),
4010 ]);
4011 let mut state = UiState::new();
4012 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4013 assert!(state.layout.warned_duplicate_ids.is_empty());
4014 }
4015
4016 #[test]
4021 fn align_center_shrinks_fill_child_to_intrinsic() {
4022 let mut root = column([crate::row([crate::widgets::text::text("hi")
4026 .width(Size::Fixed(40.0))
4027 .height(Size::Fixed(20.0))])])
4028 .align(Align::Center)
4029 .width(Size::Fixed(200.0))
4030 .height(Size::Fixed(100.0));
4031 let mut state = UiState::new();
4032 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4033 let row_rect = root.children[0].computed_rect;
4034 assert!(
4037 (row_rect.x - 80.0).abs() < 0.5,
4038 "expected x≈80 (centered), got {}",
4039 row_rect.x
4040 );
4041 assert!(
4042 (row_rect.w - 40.0).abs() < 0.5,
4043 "expected w≈40 (shrunk to intrinsic), got {}",
4044 row_rect.w
4045 );
4046 }
4047
4048 #[test]
4052 fn max_clamped_fill_frees_space_to_sibling_fills() {
4053 let mut root = crate::row([
4054 El::new(Kind::Group).width(Size::Fill(1.0)).max_width(50.0),
4055 El::new(Kind::Group).width(Size::Fill(1.0)),
4056 ])
4057 .width(Size::Fixed(400.0))
4058 .height(Size::Fixed(100.0));
4059 let mut state = UiState::new();
4060 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4061 let a = root.children[0].computed_rect;
4062 let b = root.children[1].computed_rect;
4063 assert!((a.w - 50.0).abs() < 0.5, "capped fill w={}", a.w);
4064 assert!(
4065 (b.w - 350.0).abs() < 0.5,
4066 "sibling fill should absorb the freed 150px, got w={}",
4067 b.w
4068 );
4069 }
4070
4071 #[test]
4075 fn min_clamped_fill_steals_space_from_sibling_fills() {
4076 let mut root = crate::row([
4077 El::new(Kind::Group).width(Size::Fill(1.0)).min_width(300.0),
4078 El::new(Kind::Group).width(Size::Fill(1.0)),
4079 ])
4080 .width(Size::Fixed(400.0))
4081 .height(Size::Fixed(100.0));
4082 let mut state = UiState::new();
4083 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4084 let a = root.children[0].computed_rect;
4085 let b = root.children[1].computed_rect;
4086 assert!((a.w - 300.0).abs() < 0.5, "floored fill w={}", a.w);
4087 assert!(
4088 (b.w - 100.0).abs() < 0.5,
4089 "sibling fill should shrink to the remaining 100px, got w={}",
4090 b.w
4091 );
4092 }
4093
4094 #[test]
4098 fn justify_distributes_space_left_by_fully_capped_fills() {
4099 let mut root = crate::row([El::new(Kind::Group).width(Size::Fill(1.0)).max_width(100.0)])
4100 .justify(Justify::Center)
4101 .width(Size::Fixed(400.0))
4102 .height(Size::Fixed(100.0));
4103 let mut state = UiState::new();
4104 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4105 let a = root.children[0].computed_rect;
4106 assert!((a.w - 100.0).abs() < 0.5, "capped fill w={}", a.w);
4107 assert!(
4108 (a.x - 150.0).abs() < 0.5,
4109 "capped fill should be centered (x≈150), got x={}",
4110 a.x
4111 );
4112 }
4113
4114 #[test]
4117 fn align_stretch_preserves_fill_stretch() {
4118 let mut root = column([crate::row([crate::widgets::text::text("hi")
4119 .width(Size::Fixed(40.0))
4120 .height(Size::Fixed(20.0))])])
4121 .align(Align::Stretch)
4122 .width(Size::Fixed(200.0))
4123 .height(Size::Fixed(100.0));
4124 let mut state = UiState::new();
4125 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4126 let row_rect = root.children[0].computed_rect;
4127 assert!(
4128 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
4129 "expected stretched (x=0, w=200), got x={} w={}",
4130 row_rect.x,
4131 row_rect.w
4132 );
4133 }
4134
4135 #[test]
4142 fn children_of_text_bearing_node_still_get_sized() {
4143 let mut root = column([El::new(Kind::Group)
4144 .text("label".to_string())
4145 .child(
4146 crate::widgets::text::text("nested child")
4147 .width(Size::Hug)
4148 .height(Size::Hug),
4149 )
4150 .width(Size::Fixed(300.0))
4151 .height(Size::Fixed(100.0))]);
4152 let mut state = UiState::new();
4153 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 100.0));
4154 let child = root.children[0].children[0].computed_rect;
4155 assert!(
4156 child.w > 10.0 && child.h > 5.0,
4157 "hug child of a text-bearing parent must size from its own \
4158 content, got {child:?}"
4159 );
4160 }
4161
4162 #[test]
4169 fn clamped_fill_resizes_wrap_text_descendants_at_final_width() {
4170 let long = "words that will definitely wrap at two hundred pixels \
4171 but not at three hundred, repeated for effect and \
4172 measure, wrapping across several lines";
4173 let make = |max_w: Option<f32>| {
4174 let mut fill = column([crate::widgets::text::text(long).wrap_text()])
4175 .width(Size::Fill(1.0))
4176 .height(Size::Hug);
4177 if let Some(m) = max_w {
4178 fill.max_width = Some(m);
4179 }
4180 crate::row([
4181 crate::tree::spacer()
4182 .width(Size::Fixed(100.0))
4183 .height(Size::Fixed(10.0)),
4184 fill,
4185 ])
4186 .width(Size::Fixed(400.0))
4187 .height(Size::Fixed(400.0))
4188 };
4189 let mut clamped = make(Some(200.0));
4192 let mut state = UiState::new();
4193 layout(&mut clamped, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4194 let col = &clamped.children[1];
4195 let col_rect = col.computed_rect;
4196 assert!(
4197 (col_rect.w - 200.0).abs() < 0.5,
4198 "fill should clamp to 200, got {}",
4199 col_rect.w
4200 );
4201 let para = col.children[0].computed_rect;
4205 let (_, expected_h) = intrinsic_constrained(&col.children[0], Some(200.0));
4206 assert!(
4207 (para.h - expected_h).abs() < 0.5,
4208 "paragraph should reserve wrap-at-200 height {expected_h}, got {}",
4209 para.h
4210 );
4211 }
4212
4213 #[test]
4220 fn hug_ancestor_measures_wrap_text_at_its_own_fixed_width() {
4221 let long = "The quick brown fox jumps over the lazy dog, then \
4222 does it again and again until the line is long \
4223 enough to wrap several times.";
4224 let mut root = column([column([column([crate::widgets::text::text(long)
4228 .wrap_text()
4229 .width(Size::Fixed(200.0))])
4230 .width(Size::Fixed(240.0))])
4231 .width(Size::Fill(1.0))]);
4232 let mut state = UiState::new();
4233 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4234 let card = root.children[0].computed_rect;
4235 let text_rect = root.children[0].children[0].children[0].computed_rect;
4236 assert!(
4237 text_rect.h > 25.0,
4238 "text should wrap to multiple lines at 200px, got h={}",
4239 text_rect.h
4240 );
4241 assert!(
4242 (card.h - text_rect.h).abs() < 0.5,
4243 "Hug card height {} must match wrapped text height {}",
4244 card.h,
4245 text_rect.h
4246 );
4247 }
4248
4249 #[test]
4252 fn justify_center_centers_hug_children() {
4253 let mut root = column([crate::widgets::text::text("hi")
4254 .width(Size::Fixed(40.0))
4255 .height(Size::Fixed(20.0))])
4256 .justify(Justify::Center)
4257 .height(Size::Fill(1.0));
4258 let mut state = UiState::new();
4259 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4260 let child_rect = root.children[0].computed_rect;
4261 assert!(
4263 (child_rect.y - 40.0).abs() < 0.5,
4264 "expected y≈40, got {}",
4265 child_rect.y
4266 );
4267 }
4268
4269 #[test]
4270 fn justify_end_pushes_to_bottom() {
4271 let mut root = column([crate::widgets::text::text("hi")
4272 .width(Size::Fixed(40.0))
4273 .height(Size::Fixed(20.0))])
4274 .justify(Justify::End)
4275 .height(Size::Fill(1.0));
4276 let mut state = UiState::new();
4277 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
4278 let child_rect = root.children[0].computed_rect;
4279 assert!(
4280 (child_rect.y - 80.0).abs() < 0.5,
4281 "expected y≈80, got {}",
4282 child_rect.y
4283 );
4284 }
4285
4286 #[test]
4290 fn justify_space_between_distributes_evenly() {
4291 let row_child = || {
4292 crate::widgets::text::text("x")
4293 .width(Size::Fixed(20.0))
4294 .height(Size::Fixed(20.0))
4295 };
4296 let mut root = column([row_child(), row_child(), row_child()])
4297 .justify(Justify::SpaceBetween)
4298 .height(Size::Fixed(200.0));
4299 let mut state = UiState::new();
4300 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
4301 let y0 = root.children[0].computed_rect.y;
4304 let y1 = root.children[1].computed_rect.y;
4305 let y2 = root.children[2].computed_rect.y;
4306 assert!(
4307 y0.abs() < 0.5,
4308 "first child should be flush at y=0, got {y0}"
4309 );
4310 assert!(
4311 (y1 - 90.0).abs() < 0.5,
4312 "middle child should be at y≈90, got {y1}"
4313 );
4314 assert!(
4315 (y2 - 180.0).abs() < 0.5,
4316 "last child should be flush at y≈180, got {y2}"
4317 );
4318 }
4319
4320 #[test]
4324 fn fill_weight_distributes_proportionally() {
4325 let big = crate::widgets::text::text("big")
4326 .width(Size::Fixed(40.0))
4327 .height(Size::Fill(2.0));
4328 let small = crate::widgets::text::text("small")
4329 .width(Size::Fixed(40.0))
4330 .height(Size::Fill(1.0));
4331 let mut root = column([big, small]).height(Size::Fixed(300.0));
4332 let mut state = UiState::new();
4333 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
4334 let big_h = root.children[0].computed_rect.h;
4336 let small_h = root.children[1].computed_rect.h;
4337 assert!(
4338 (big_h - 200.0).abs() < 0.5,
4339 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
4340 );
4341 assert!(
4342 (small_h - 100.0).abs() < 0.5,
4343 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
4344 );
4345 }
4346
4347 #[test]
4351 fn padding_on_hug_includes_in_intrinsic() {
4352 let root = column([crate::widgets::text::text("x")
4353 .width(Size::Fixed(40.0))
4354 .height(Size::Fixed(40.0))])
4355 .padding(Sides::all(20.0));
4356 let (w, h) = intrinsic(&root);
4357 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
4359 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
4360 }
4361
4362 #[test]
4366 fn align_end_pins_to_cross_axis_far_edge() {
4367 let mut root = crate::row([crate::widgets::text::text("hi")
4368 .width(Size::Fixed(40.0))
4369 .height(Size::Fixed(20.0))])
4370 .align(Align::End)
4371 .width(Size::Fixed(200.0))
4372 .height(Size::Fixed(100.0));
4373 let mut state = UiState::new();
4374 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
4375 let child_rect = root.children[0].computed_rect;
4376 assert!(
4378 (child_rect.y - 80.0).abs() < 0.5,
4379 "expected y≈80 (pinned to bottom), got {}",
4380 child_rect.y
4381 );
4382 }
4383
4384 #[test]
4385 fn overlay_can_center_hug_child() {
4386 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
4387 .width(Size::Fixed(200.0))
4388 .height(Size::Hug)])
4389 .align(Align::Center)
4390 .justify(Justify::Center);
4391 let mut state = UiState::new();
4392 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
4393 let child_rect = root.children[0].computed_rect;
4394 assert!(
4395 (child_rect.x - 200.0).abs() < 0.5,
4396 "expected x≈200, got {}",
4397 child_rect.x
4398 );
4399 assert!(
4400 child_rect.y > 100.0 && child_rect.y < 200.0,
4401 "expected centered y, got {}",
4402 child_rect.y
4403 );
4404 }
4405
4406 #[test]
4407 fn scroll_offset_translates_children_and_clamps_to_content() {
4408 let mut root = scroll(
4412 (0..6)
4413 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4414 )
4415 .key("list")
4416 .gap(12.0)
4417 .height(Size::Fixed(200.0));
4418 let mut state = UiState::new();
4419 assign_ids(&mut root);
4420 state
4421 .scroll
4422 .offsets
4423 .insert(root.computed_id.clone().to_string(), 80.0);
4424
4425 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4426
4427 let stored = state
4429 .scroll
4430 .offsets
4431 .get(&*root.computed_id)
4432 .copied()
4433 .unwrap_or(0.0);
4434 assert!(
4435 (stored - 80.0).abs() < 0.01,
4436 "offset clamped unexpectedly: {stored}"
4437 );
4438 let c0 = root.children[0].computed_rect;
4440 assert!(
4441 (c0.y - (-80.0)).abs() < 0.01,
4442 "child 0 y = {} (expected -80)",
4443 c0.y
4444 );
4445 state
4447 .scroll
4448 .offsets
4449 .insert(root.computed_id.clone().to_string(), 9999.0);
4450 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4451 let stored = state
4452 .scroll
4453 .offsets
4454 .get(&*root.computed_id)
4455 .copied()
4456 .unwrap_or(0.0);
4457 assert!(
4458 (stored - 160.0).abs() < 0.01,
4459 "overshoot clamped to {stored}"
4460 );
4461 let mut tiny =
4463 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
4464 .height(Size::Fixed(200.0));
4465 let mut tiny_state = UiState::new();
4466 assign_ids(&mut tiny);
4467 tiny_state
4468 .scroll
4469 .offsets
4470 .insert(tiny.computed_id.clone().to_string(), 50.0);
4471 layout(
4472 &mut tiny,
4473 &mut tiny_state,
4474 Rect::new(0.0, 0.0, 300.0, 200.0),
4475 );
4476 assert_eq!(
4477 tiny_state
4478 .scroll
4479 .offsets
4480 .get(&*tiny.computed_id)
4481 .copied()
4482 .unwrap_or(0.0),
4483 0.0
4484 );
4485 }
4486
4487 #[test]
4488 fn scroll_layout_prunes_far_offscreen_descendants() {
4489 let far = column([crate::widgets::text::text("far row body").key("far-text")])
4490 .height(Size::Fixed(40.0));
4491 let mut root = scroll([
4492 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
4493 crate::tree::spacer().height(Size::Fixed(400.0)),
4494 far,
4495 ])
4496 .key("list")
4497 .height(Size::Fixed(80.0));
4498 let mut state = UiState::new();
4499 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
4500 let stats = take_prune_stats();
4501
4502 assert!(
4503 stats.subtrees >= 1,
4504 "expected at least one far scroll child to be pruned, got {stats:?}"
4505 );
4506 assert!(
4507 stats.nodes >= 1,
4508 "expected pruned descendants to be zeroed, got {stats:?}"
4509 );
4510 let far_text = state
4511 .rect_of_key("far-text")
4512 .expect("far text keeps a zero rect while pruned");
4513 assert_eq!(far_text.w, 0.0);
4514 assert_eq!(far_text.h, 0.0);
4515 }
4516
4517 #[test]
4518 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
4519 let make_root = || {
4520 let paragraph_text = "Variable width text wraps into a different number of lines when \
4521 the viewport narrows, which used to make a plain scroll box lose \
4522 the item the user was reading.";
4523 scroll([column((0..30).map(|i| {
4524 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
4525 .key(format!("paragraph-{i}"))
4526 }))
4527 .gap(8.0)])
4528 .key("article")
4529 .height(Size::Fixed(180.0))
4530 };
4531
4532 let mut root = make_root();
4533 let mut state = UiState::new();
4534 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4535
4536 state
4537 .scroll
4538 .offsets
4539 .insert(root.computed_id.clone().to_string(), 520.0);
4540 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
4541
4542 let anchor = state
4543 .scroll
4544 .scroll_anchors
4545 .get(&*root.computed_id)
4546 .cloned()
4547 .expect("plain scroll should store a visible descendant anchor");
4548 let before_rect = find_descendant_rect(&root, &anchor.node_id).expect("anchor rect before");
4549 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
4550 let before_offset = state.scroll_offset(&root.computed_id);
4551
4552 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
4553
4554 let after_rect = find_descendant_rect(&root, &anchor.node_id).expect("anchor rect after");
4555 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
4556 let after_offset = state.scroll_offset(&root.computed_id);
4557 assert!(
4558 (after_anchor_y - before_anchor_y).abs() < 0.5,
4559 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
4560 );
4561 assert!(
4562 (after_offset - before_offset).abs() > 20.0,
4563 "offset should absorb height changes above the anchor"
4564 );
4565 }
4566
4567 #[test]
4568 fn scrollbar_thumb_size_and_position_track_overflow() {
4569 let mut root = scroll(
4572 (0..6)
4573 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4574 )
4575 .gap(12.0)
4576 .height(Size::Fixed(200.0));
4577 let mut state = UiState::new();
4578 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4579
4580 let metrics = state
4581 .scroll
4582 .metrics
4583 .get(&*root.computed_id)
4584 .copied()
4585 .expect("scrollable should have metrics");
4586 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
4587 assert!((metrics.content_h - 360.0).abs() < 0.01);
4588 assert!((metrics.max_offset - 160.0).abs() < 0.01);
4589
4590 let thumb = state
4591 .scroll
4592 .thumb_rects
4593 .get(&*root.computed_id)
4594 .copied()
4595 .expect("scrollable with scrollbar() and overflow gets a thumb");
4596 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
4598 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
4599 assert!(thumb.y.abs() < 0.01);
4601 assert!(
4603 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
4604 "thumb anchored at {} (expected {})",
4605 thumb.x,
4606 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
4607 );
4608
4609 state
4611 .scroll
4612 .offsets
4613 .insert(root.computed_id.clone().to_string(), 80.0);
4614 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4615 let thumb = state
4616 .scroll
4617 .thumb_rects
4618 .get(&*root.computed_id)
4619 .copied()
4620 .unwrap();
4621 let track_remaining = 200.0 - thumb.h;
4622 let expected_y = track_remaining * (80.0 / 160.0);
4623 assert!(
4624 (thumb.y - expected_y).abs() < 0.5,
4625 "thumb at half-scroll y = {} (expected {expected_y})",
4626 thumb.y,
4627 );
4628 }
4629
4630 #[test]
4631 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
4632 let mut root = scroll(
4636 (0..6)
4637 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4638 )
4639 .gap(12.0)
4640 .height(Size::Fixed(200.0));
4641 let mut state = UiState::new();
4642 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4643
4644 let thumb = state
4645 .scroll
4646 .thumb_rects
4647 .get(&*root.computed_id)
4648 .copied()
4649 .unwrap();
4650 let track = state
4651 .scroll
4652 .thumb_tracks
4653 .get(&*root.computed_id)
4654 .copied()
4655 .unwrap();
4656 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
4658 assert!(
4659 (track.right() - thumb.right()).abs() < 0.01,
4660 "track and thumb must share the right edge",
4661 );
4662 assert!(
4665 (track.h - 200.0).abs() < 0.01,
4666 "track height = {} (expected 200)",
4667 track.h,
4668 );
4669 }
4670
4671 #[test]
4672 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
4673 let mut suppressed = scroll(
4675 (0..6)
4676 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
4677 )
4678 .no_scrollbar()
4679 .height(Size::Fixed(200.0));
4680 let mut state = UiState::new();
4681 layout(
4682 &mut suppressed,
4683 &mut state,
4684 Rect::new(0.0, 0.0, 300.0, 200.0),
4685 );
4686 assert!(
4687 !state
4688 .scroll
4689 .thumb_rects
4690 .contains_key(&*suppressed.computed_id)
4691 );
4692
4693 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
4695 .height(Size::Fixed(200.0));
4696 let mut tiny_state = UiState::new();
4697 layout(
4698 &mut tiny,
4699 &mut tiny_state,
4700 Rect::new(0.0, 0.0, 300.0, 200.0),
4701 );
4702 assert!(
4703 !tiny_state
4704 .scroll
4705 .thumb_rects
4706 .contains_key(&*tiny.computed_id)
4707 );
4708 }
4709
4710 #[test]
4711 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
4712 let make_root = || {
4713 scroll([
4714 crate::tree::spacer().height(Size::Fixed(80.0)),
4715 scroll((0..6).map(|i| {
4716 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
4717 }))
4718 .key("inner")
4719 .height(Size::Fixed(120.0)),
4720 crate::tree::spacer().height(Size::Fixed(260.0)),
4721 ])
4722 .key("outer")
4723 .height(Size::Fixed(220.0))
4724 };
4725
4726 let mut root = make_root();
4727 let mut state = UiState::new();
4728 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4729 let inner = root
4730 .children
4731 .iter()
4732 .find(|child| child.key.as_deref() == Some("inner"))
4733 .expect("inner scroll");
4734 let inner_id = inner.computed_id.clone();
4735 let inner_rect = state.rect(&inner_id);
4736 let thumb = state
4737 .scroll
4738 .thumb_rects
4739 .get(&*inner_id)
4740 .copied()
4741 .expect("inner scroll should have a thumb");
4742 let track = state
4743 .scroll
4744 .thumb_tracks
4745 .get(&*inner_id)
4746 .copied()
4747 .expect("inner scroll should have a track");
4748 let thumb_rel_y = thumb.y - inner_rect.y;
4749 let track_rel_y = track.y - inner_rect.y;
4750
4751 state
4752 .scroll
4753 .offsets
4754 .insert(root.computed_id.clone().to_string(), 60.0);
4755 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
4756 let inner_rect_after = state.rect(&inner_id);
4757 let thumb_after = state.scroll.thumb_rects.get(&*inner_id).copied().unwrap();
4758 let track_after = state.scroll.thumb_tracks.get(&*inner_id).copied().unwrap();
4759
4760 assert!(
4761 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
4762 "outer scroll should shift the inner viewport"
4763 );
4764 assert!(
4765 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
4766 "inner thumb should stay fixed relative to its viewport"
4767 );
4768 assert!(
4769 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
4770 "inner track should stay fixed relative to its viewport"
4771 );
4772 }
4773
4774 #[test]
4775 fn layout_override_places_children_at_returned_rects() {
4776 let mut root = column((0..3).map(|i| {
4778 crate::widgets::text::text(format!("dot {i}"))
4779 .width(Size::Fixed(20.0))
4780 .height(Size::Fixed(20.0))
4781 }))
4782 .width(Size::Fixed(200.0))
4783 .height(Size::Fixed(200.0))
4784 .layout(|ctx| {
4785 ctx.children
4786 .iter()
4787 .enumerate()
4788 .map(|(i, _)| {
4789 let off = i as f32 * 30.0;
4790 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
4791 })
4792 .collect()
4793 });
4794 let mut state = UiState::new();
4795 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4796 let r0 = root.children[0].computed_rect;
4797 let r1 = root.children[1].computed_rect;
4798 let r2 = root.children[2].computed_rect;
4799 assert_eq!((r0.x, r0.y), (0.0, 0.0));
4800 assert_eq!((r1.x, r1.y), (30.0, 30.0));
4801 assert_eq!((r2.x, r2.y), (60.0, 60.0));
4802 }
4803
4804 #[test]
4805 fn layout_override_rect_of_key_resolves_earlier_sibling() {
4806 use crate::tree::stack;
4812 let trigger_x = 40.0;
4813 let trigger_y = 20.0;
4814 let trigger_w = 60.0;
4815 let trigger_h = 30.0;
4816 let mut root = stack([
4817 crate::widgets::button::button("Open")
4819 .key("trig")
4820 .width(Size::Fixed(trigger_w))
4821 .height(Size::Fixed(trigger_h)),
4822 stack([crate::widgets::text::text("popover")
4825 .width(Size::Fixed(80.0))
4826 .height(Size::Fixed(20.0))])
4827 .width(Size::Fill(1.0))
4828 .height(Size::Fill(1.0))
4829 .layout(|ctx| {
4830 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
4831 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
4832 }),
4833 ])
4834 .padding(Sides::xy(trigger_x, trigger_y));
4835 let mut state = UiState::new();
4836 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4837
4838 let popover_layer = &root.children[1];
4839 let panel_rect = popover_layer.children[0].computed_rect;
4840 assert!(
4843 (panel_rect.x - trigger_x).abs() < 0.01,
4844 "popover x = {} (expected {trigger_x})",
4845 panel_rect.x,
4846 );
4847 assert!(
4848 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
4849 "popover y = {} (expected {})",
4850 panel_rect.y,
4851 trigger_y + trigger_h + 4.0,
4852 );
4853 }
4854
4855 #[test]
4856 fn layout_override_rect_of_key_returns_none_for_missing_key() {
4857 let mut root = column([crate::widgets::text::text("inner")
4858 .width(Size::Fixed(40.0))
4859 .height(Size::Fixed(20.0))])
4860 .width(Size::Fixed(200.0))
4861 .height(Size::Fixed(200.0))
4862 .layout(|ctx| {
4863 assert!((ctx.rect_of_key)("nope").is_none());
4864 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
4865 });
4866 let mut state = UiState::new();
4867 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4868 }
4869
4870 #[test]
4871 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
4872 use crate::tree::stack;
4878 let mut root = stack([
4879 stack([crate::widgets::text::text("panel")
4880 .width(Size::Fixed(40.0))
4881 .height(Size::Fixed(20.0))])
4882 .width(Size::Fill(1.0))
4883 .height(Size::Fill(1.0))
4884 .layout(|ctx| {
4885 assert!(
4886 (ctx.rect_of_key)("later").is_none(),
4887 "later sibling's rect must not be available yet"
4888 );
4889 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
4890 }),
4891 crate::widgets::button::button("after").key("later"),
4892 ]);
4893 let mut state = UiState::new();
4894 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
4895 }
4896
4897 #[test]
4898 fn layout_override_measure_returns_intrinsic() {
4899 let mut root = column([crate::widgets::text::text("hi")
4901 .width(Size::Fixed(40.0))
4902 .height(Size::Fixed(20.0))])
4903 .width(Size::Fixed(200.0))
4904 .height(Size::Fixed(200.0))
4905 .layout(|ctx| {
4906 let (w, h) = (ctx.measure)(&ctx.children[0]);
4907 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
4908 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
4909 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
4910 });
4911 let mut state = UiState::new();
4912 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4913 let r = root.children[0].computed_rect;
4914 assert_eq!((r.w, r.h), (40.0, 20.0));
4915 }
4916
4917 #[test]
4918 #[should_panic(expected = "returned 1 rects for 2 children")]
4919 fn layout_override_length_mismatch_panics() {
4920 let mut root = column([
4921 crate::widgets::text::text("a")
4922 .width(Size::Fixed(10.0))
4923 .height(Size::Fixed(10.0)),
4924 crate::widgets::text::text("b")
4925 .width(Size::Fixed(10.0))
4926 .height(Size::Fixed(10.0)),
4927 ])
4928 .width(Size::Fixed(200.0))
4929 .height(Size::Fixed(200.0))
4930 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
4931 let mut state = UiState::new();
4932 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4933 }
4934
4935 #[test]
4936 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
4937 fn layout_override_hug_panics() {
4938 let mut root = column([column([crate::widgets::text::text("c")])
4942 .width(Size::Hug)
4943 .height(Size::Fixed(200.0))
4944 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
4945 .width(Size::Fixed(200.0))
4946 .height(Size::Fixed(200.0));
4947 let mut state = UiState::new();
4948 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4949 }
4950
4951 #[test]
4952 fn fit_contain_letterboxes_and_centers() {
4953 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted");
4956 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
4957 .width(Size::Fixed(400.0))
4958 .height(Size::Fixed(400.0));
4959 let mut state = UiState::new();
4960 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4961
4962 let r = state.rect_of_key("fitted").expect("fitted rect");
4963 assert_eq!((r.w, r.h), (400.0, 225.0));
4964 assert_eq!(r.x, 0.0);
4965 assert!((r.y - 87.5).abs() < 0.01, "centered: y = {}", r.y);
4966
4967 let child = crate::tree::column([crate::widgets::text::text("c")]).key("fitted2");
4969 let mut root = crate::tree::fit_contain(child, 16.0 / 9.0)
4970 .width(Size::Fixed(400.0))
4971 .height(Size::Fixed(100.0));
4972 let mut state = UiState::new();
4973 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
4974 let r = state.rect_of_key("fitted2").expect("fitted2 rect");
4975 assert!((r.w - 100.0 * 16.0 / 9.0).abs() < 0.01);
4976 assert_eq!(r.h, 100.0);
4977 assert!((r.x - (400.0 - r.w) / 2.0).abs() < 0.01);
4978 }
4979
4980 #[test]
4981 fn fit_cover_overflows_the_slack_axis_and_clips() {
4982 let child = crate::tree::column([crate::widgets::text::text("c")]).key("covered");
4986 let mut root = crate::tree::fit_cover(child, 1.0)
4987 .width(Size::Fixed(400.0))
4988 .height(Size::Fixed(200.0));
4989 assert!(root.clip, "fit_cover must clip its overflow");
4990 let mut state = UiState::new();
4991 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
4992 let r = state.rect_of_key("covered").expect("covered rect");
4993 assert_eq!((r.w, r.h), (400.0, 400.0));
4994 assert!(
4995 (r.y - -100.0).abs() < 0.01,
4996 "centered overflow: y = {}",
4997 r.y
4998 );
4999 }
5000
5001 #[test]
5002 fn fit_contain_intrinsic_uses_the_child_measure() {
5003 let child = crate::tree::column::<Vec<El>, El>(vec![])
5005 .width(Size::Fixed(100.0))
5006 .height(Size::Fixed(50.0))
5007 .key("intrinsic");
5008 let mut root = crate::tree::fit_contain_intrinsic(child)
5009 .width(Size::Fixed(400.0))
5010 .height(Size::Fixed(400.0));
5011 let mut state = UiState::new();
5012 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
5013 let r = state.rect_of_key("intrinsic").expect("intrinsic rect");
5014 assert_eq!((r.w, r.h), (400.0, 200.0));
5015 }
5016
5017 #[test]
5018 fn virtual_list_realizes_only_visible_rows() {
5019 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
5023 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5024 });
5025 let mut state = UiState::new();
5026 assign_ids(&mut root);
5027 state
5028 .scroll
5029 .offsets
5030 .insert(root.computed_id.clone().to_string(), 120.0);
5031 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5032
5033 assert_eq!(
5034 root.children.len(),
5035 5,
5036 "expected 5 realized rows, got {}",
5037 root.children.len()
5038 );
5039 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
5041 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
5042 let r0 = root.children[0].computed_rect;
5044 assert!(
5045 (r0.y - (-20.0)).abs() < 0.5,
5046 "row 2 expected y≈-20, got {}",
5047 r0.y
5048 );
5049 }
5050
5051 #[test]
5052 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
5053 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
5054 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5055 })
5056 .gap(10.0);
5057 let mut state = UiState::new();
5058 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5059
5060 assert_eq!(
5061 root.children.len(),
5062 3,
5063 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5064 );
5065 let row_1 = root
5066 .children
5067 .iter()
5068 .find(|c| c.key.as_deref() == Some("row-1"))
5069 .expect("row 1 should be realized");
5070 assert!(
5071 (row_1.computed_rect.y - 50.0).abs() < 0.5,
5072 "gap should place row 1 at y=50"
5073 );
5074 let metrics = state
5075 .scroll
5076 .metrics
5077 .get(&*root.computed_id)
5078 .expect("virtual list writes scroll metrics");
5079 assert!(
5080 (metrics.content_h - 490.0).abs() < 0.5,
5081 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5082 metrics.content_h
5083 );
5084 }
5085
5086 #[test]
5087 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
5088 let make_root = || {
5089 crate::tree::virtual_list(50, 50.0, |i| {
5090 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5091 })
5092 };
5093
5094 let mut state = UiState::new();
5095 let mut root_a = make_root();
5096 assign_ids(&mut root_a);
5097 state
5099 .scroll
5100 .offsets
5101 .insert(root_a.computed_id.clone().to_string(), 250.0);
5102 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5103 let id_at_offset_a = root_a
5104 .children
5105 .iter()
5106 .find(|c| c.key.as_deref() == Some("row-5"))
5107 .unwrap()
5108 .computed_id
5109 .clone();
5110
5111 let mut root_b = make_root();
5113 assign_ids(&mut root_b);
5114 state
5115 .scroll
5116 .offsets
5117 .insert(root_b.computed_id.clone().to_string(), 200.0);
5118 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5119 let id_at_offset_b = root_b
5120 .children
5121 .iter()
5122 .find(|c| c.key.as_deref() == Some("row-5"))
5123 .unwrap()
5124 .computed_id
5125 .clone();
5126
5127 assert_eq!(
5128 id_at_offset_a, id_at_offset_b,
5129 "row-5's computed_id changed when scroll offset moved"
5130 );
5131 }
5132
5133 #[test]
5134 fn virtual_list_clamps_overshoot_offset() {
5135 let mut root =
5137 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
5138 let mut state = UiState::new();
5139 assign_ids(&mut root);
5140 state
5141 .scroll
5142 .offsets
5143 .insert(root.computed_id.clone().to_string(), 9999.0);
5144 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5145 let stored = state
5146 .scroll
5147 .offsets
5148 .get(&*root.computed_id)
5149 .copied()
5150 .unwrap_or(0.0);
5151 assert!(
5152 (stored - 300.0).abs() < 0.01,
5153 "expected clamp to 300, got {stored}"
5154 );
5155 }
5156
5157 #[test]
5158 fn virtual_list_empty_count_realizes_no_children() {
5159 let mut root =
5160 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
5161 let mut state = UiState::new();
5162 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5163 assert_eq!(root.children.len(), 0);
5164 }
5165
5166 #[test]
5167 #[should_panic(expected = "row_height > 0.0")]
5168 fn virtual_list_zero_row_height_panics() {
5169 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
5170 }
5171
5172 #[test]
5173 #[should_panic(expected = "Size::Hug would defeat virtualization")]
5174 fn virtual_list_hug_panics() {
5175 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
5176 crate::widgets::text::text(format!("r{i}"))
5177 })
5178 .height(Size::Hug)])
5179 .width(Size::Fixed(300.0))
5180 .height(Size::Fixed(200.0));
5181 let mut state = UiState::new();
5182 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5183 }
5184
5185 #[test]
5186 fn grid_packs_rows_and_aligns_the_partial_tail() {
5187 let cell = |i: usize| {
5191 crate::tree::column::<Vec<El>, El>(vec![])
5192 .key(format!("cell-{i}"))
5193 .height(Size::Fixed(50.0))
5194 };
5195 let mut root = crate::tree::grid(2, 10.0, (0..3).map(cell))
5196 .width(Size::Fixed(210.0))
5197 .height(Size::Fixed(300.0));
5198 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
5199 let mut state = UiState::new();
5200 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 210.0, 300.0));
5201
5202 let r0 = state.rect_of_key("cell-0").unwrap();
5203 let r1 = state.rect_of_key("cell-1").unwrap();
5204 let r2 = state.rect_of_key("cell-2").unwrap();
5205 assert_eq!((r0.x, r0.w), (0.0, 100.0));
5206 assert_eq!((r1.x, r1.w), (110.0, 100.0));
5207 assert_eq!((r2.x, r2.w), (0.0, 100.0));
5209 assert_eq!(r2.y, 60.0);
5210 }
5211
5212 #[test]
5213 fn virtual_grid_realizes_rows_of_packed_cells() {
5214 let mut root = crate::tree::virtual_grid(10, 3, 50.0, 0.0, |i| {
5217 crate::widgets::text::text(format!("item {i}")).key(format!("item-{i}"))
5218 })
5219 .key("vgrid");
5220 let mut state = UiState::new();
5221 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5222
5223 assert_eq!(root.arrow_nav, Some(crate::tree::ArrowNav::Grid));
5224 let rect_of = |row: usize, col: usize| root.children[row].children[col].computed_rect;
5229 let i0 = rect_of(0, 0);
5231 let i2 = rect_of(0, 2);
5232 assert_eq!(i0.w, 100.0);
5233 assert_eq!(i2.x, 200.0);
5234 let i3 = rect_of(1, 0);
5236 assert_eq!((i3.x, i3.y), (0.0, 50.0));
5237 assert!(state.visible_range("vgrid").is_some());
5239 assert_eq!(root.children[0].children[0].key.as_deref(), Some("item-0"));
5241 }
5242
5243 #[test]
5244 fn visible_range_tracks_realized_rows() {
5245 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
5249 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
5250 })
5251 .key("list");
5252 let mut state = UiState::new();
5253 assign_ids(&mut root);
5254 state
5255 .scroll
5256 .offsets
5257 .insert(root.computed_id.clone().to_string(), 120.0);
5258 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5259
5260 assert_eq!(state.visible_range("list"), Some(2..7));
5261 assert_eq!(state.visible_range("not-a-list"), None);
5262
5263 let mut dyn_root = crate::tree::virtual_list_dyn(
5266 20,
5267 50.0,
5268 |i| format!("row-{i}"),
5269 |i| {
5270 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
5271 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5272 .key(format!("row-{i}"))
5273 .height(Size::Fixed(h))
5274 },
5275 )
5276 .key("dyn-list");
5277 let mut dyn_state = UiState::new();
5278 layout(
5279 &mut dyn_root,
5280 &mut dyn_state,
5281 Rect::new(0.0, 0.0, 300.0, 200.0),
5282 );
5283 assert_eq!(dyn_state.visible_range("dyn-list"), Some(0..4));
5284
5285 let mut empty =
5287 crate::tree::virtual_list(0, 50.0, |_| crate::widgets::text::text("never")).key("list");
5288 layout(&mut empty, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5289 assert_eq!(state.visible_range("list"), None);
5290 }
5291
5292 #[test]
5293 fn virtual_list_dyn_respects_per_row_fixed_heights() {
5294 let mut root = crate::tree::virtual_list_dyn(
5298 20,
5299 50.0,
5300 |i| format!("row-{i}"),
5301 |i| {
5302 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
5303 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5304 .key(format!("row-{i}"))
5305 .height(Size::Fixed(h))
5306 },
5307 );
5308 let mut state = UiState::new();
5309 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5310
5311 assert_eq!(
5312 root.children.len(),
5313 4,
5314 "expected 4 realized rows, got {}",
5315 root.children.len()
5316 );
5317 let ys: Vec<f32> = root.children.iter().map(|c| c.computed_rect.y).collect();
5319 assert!(
5320 (ys[0] - 0.0).abs() < 0.5,
5321 "row 0 expected y≈0, got {}",
5322 ys[0]
5323 );
5324 assert!(
5325 (ys[1] - 40.0).abs() < 0.5,
5326 "row 1 expected y≈40, got {}",
5327 ys[1]
5328 );
5329 assert!(
5330 (ys[2] - 120.0).abs() < 0.5,
5331 "row 2 expected y≈120, got {}",
5332 ys[2]
5333 );
5334 assert!(
5335 (ys[3] - 160.0).abs() < 0.5,
5336 "row 3 expected y≈160, got {}",
5337 ys[3]
5338 );
5339 }
5340
5341 #[test]
5342 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
5343 let mut root = crate::tree::virtual_list_dyn(
5344 10,
5345 40.0,
5346 |i| format!("row-{i}"),
5347 |i| {
5348 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
5349 .key(format!("row-{i}"))
5350 .height(Size::Fixed(40.0))
5351 },
5352 )
5353 .gap(10.0);
5354 let mut state = UiState::new();
5355 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
5356
5357 assert_eq!(
5358 root.children.len(),
5359 3,
5360 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
5361 );
5362 let row_1 = root
5363 .children
5364 .iter()
5365 .find(|c| c.key.as_deref() == Some("row-1"))
5366 .expect("row 1 should be realized");
5367 assert!(
5368 (row_1.computed_rect.y - 50.0).abs() < 0.5,
5369 "gap should place row 1 at y=50"
5370 );
5371 let metrics = state
5372 .scroll
5373 .metrics
5374 .get(&*root.computed_id)
5375 .expect("virtual list writes scroll metrics");
5376 assert!(
5377 (metrics.content_h - 490.0).abs() < 0.5,
5378 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
5379 metrics.content_h
5380 );
5381 }
5382
5383 #[test]
5384 fn virtual_list_dyn_caches_measured_heights() {
5385 let mut root = crate::tree::virtual_list_dyn(
5389 50,
5390 50.0,
5391 |i| format!("row-{i}"),
5392 |i| {
5393 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5394 .key(format!("row-{i}"))
5395 .height(Size::Fixed(30.0))
5396 },
5397 );
5398 let mut state = UiState::new();
5399 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5400
5401 let measured = state
5402 .scroll
5403 .measured_row_heights
5404 .get(&*root.computed_id)
5405 .expect("dynamic virtual list should populate the height cache");
5406 assert!(
5410 measured.len() >= 6,
5411 "expected ≥ 6 cached row heights, got {}",
5412 measured.len()
5413 );
5414 for by_width in measured.values() {
5415 let h = by_width
5416 .get(&300)
5417 .copied()
5418 .expect("measurement should be keyed at the 300px width bucket");
5419 assert!(
5420 (h - 30.0).abs() < 0.5,
5421 "expected cached height ≈ 30, got {h}"
5422 );
5423 }
5424 }
5425
5426 #[test]
5427 fn virtual_list_dyn_realized_rows_place_at_their_measured_heights() {
5428 let mut root = crate::tree::virtual_list_dyn(
5435 50,
5436 20.0,
5437 |i| format!("row-{i}"),
5438 |i| {
5439 crate::tree::column([crate::widgets::text::text(format!("row body {i}"))])
5440 .key(format!("row-{i}"))
5441 .height(Size::Hug)
5442 },
5443 );
5444 let mut state = UiState::new();
5445 let _ = take_intrinsic_cache_stats();
5446 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5447 assert!(!root.children.is_empty(), "test requires realized rows");
5448 let stored = state
5449 .scroll
5450 .measured_row_heights
5451 .get(&*root.computed_id)
5452 .expect("dynamic list stores per-row heights");
5453 let bucket = virtual_width_bucket(300.0);
5454 for row in &root.children {
5455 let key = row.key.as_deref().expect("rows are keyed");
5456 let h = stored
5457 .get(key)
5458 .and_then(|by_width| by_width.get(&bucket))
5459 .copied()
5460 .expect("realized row height stored at the current width bucket");
5461 assert!(
5462 (row.computed_rect.h - h).abs() < 0.01,
5463 "row {key} placed at h={} but measured h={h}",
5464 row.computed_rect.h,
5465 );
5466 }
5467 let stats = take_intrinsic_cache_stats();
5470 assert_eq!(stats.hits, 0);
5471 assert!(stats.misses > 0, "sizing visits should be reported");
5472 }
5473
5474 #[test]
5475 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
5476 let make_root = || {
5477 crate::tree::virtual_list_dyn(
5478 100,
5479 40.0,
5480 |i| format!("row-{i}"),
5481 |i| {
5482 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5483 .key(format!("row-{i}"))
5484 .height(Size::Fixed(40.0))
5485 },
5486 )
5487 };
5488 let mut root = make_root();
5489 let mut state = UiState::new();
5490 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5491
5492 state
5493 .scroll
5494 .offsets
5495 .insert(root.computed_id.clone().to_string(), 400.0);
5496 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5497
5498 let anchor = state
5499 .scroll
5500 .virtual_anchors
5501 .get(&*root.computed_id)
5502 .cloned()
5503 .expect("dynamic list should store a visible anchor");
5504 let before_y = root
5505 .children
5506 .iter()
5507 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5508 .map(|child| child.computed_rect.y)
5509 .expect("anchor row should be realized");
5510 let before_offset = state.scroll_offset(&root.computed_id);
5511
5512 state
5513 .scroll
5514 .measured_row_heights
5515 .entry(root.computed_id.clone().to_string())
5516 .or_default()
5517 .entry("row-0".to_string())
5518 .or_default()
5519 .insert(300, 120.0);
5520
5521 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5522 let after_y = root
5523 .children
5524 .iter()
5525 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
5526 .map(|child| child.computed_rect.y)
5527 .expect("anchor row should remain realized");
5528 let after_offset = state.scroll_offset(&root.computed_id);
5529
5530 assert!(
5531 (after_y - before_y).abs() < 0.5,
5532 "anchor row should stay at y={before_y}, got {after_y}"
5533 );
5534 assert!(
5535 (after_offset - (before_offset + 80.0)).abs() < 0.5,
5536 "offset should absorb the 80px measurement delta above anchor"
5537 );
5538 }
5539
5540 #[test]
5541 fn virtual_list_dyn_height_cache_is_width_bucketed() {
5542 let mut root = crate::tree::virtual_list_dyn(
5543 20,
5544 50.0,
5545 |i| format!("row-{i}"),
5546 |i| {
5547 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5548 .key(format!("row-{i}"))
5549 .height(Size::Fixed(30.0))
5550 },
5551 );
5552 let mut state = UiState::new();
5553 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5554 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
5555
5556 let row_0 = state
5557 .scroll
5558 .measured_row_heights
5559 .get(&*root.computed_id)
5560 .and_then(|m| m.get("row-0"))
5561 .expect("row 0 should be measured");
5562 assert!(
5563 row_0.contains_key(&300) && row_0.contains_key(&240),
5564 "expected width buckets 300 and 240, got {:?}",
5565 row_0.keys().collect::<Vec<_>>()
5566 );
5567 }
5568
5569 #[test]
5570 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
5571 let make_root = || {
5576 crate::tree::virtual_list_dyn(
5577 20,
5578 50.0,
5579 |i| format!("row-{i}"),
5580 |i| {
5581 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
5582 .key(format!("row-{i}"))
5583 .height(Size::Fixed(30.0))
5584 },
5585 )
5586 };
5587 let mut state = UiState::new();
5588 let mut root = make_root();
5589 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5590
5591 state
5592 .scroll
5593 .offsets
5594 .insert(root.computed_id.clone().to_string(), 9999.0);
5595 let mut root2 = make_root();
5596 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5597
5598 let measured = state
5599 .scroll
5600 .measured_row_heights
5601 .get(&*root2.computed_id)
5602 .expect("dynamic virtual list should populate the height cache");
5603 let measured_sum = measured
5604 .values()
5605 .filter_map(|by_width| by_width.get(&300))
5606 .sum::<f32>();
5607 let measured_count = measured
5608 .values()
5609 .filter(|by_width| by_width.contains_key(&300))
5610 .count();
5611 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
5612 let expected_max_offset = expected_total - 200.0;
5613
5614 let stored = state
5615 .scroll
5616 .offsets
5617 .get(&*root2.computed_id)
5618 .copied()
5619 .unwrap_or(0.0);
5620 assert!(
5621 (stored - expected_max_offset).abs() < 0.5,
5622 "expected offset clamped to {expected_max_offset}, got {stored}"
5623 );
5624 }
5625
5626 fn parity_list(first: usize, count: usize, append_only: bool, pin: bool) -> El {
5640 let mut el = crate::tree::virtual_list_dyn(
5641 count,
5642 20.0,
5643 move |i| format!("m{}", first + i),
5644 move |i| {
5645 let id = first + i;
5646 let h = 30.0 + (id % 7) as f32 * 10.0;
5647 crate::tree::column([crate::widgets::text::text(format!("m{id}"))])
5648 .key(format!("m{id}"))
5649 .height(Size::Fixed(h))
5650 },
5651 )
5652 .key("chat")
5653 .gap(4.0);
5654 if pin {
5655 el = el.pin_end();
5656 }
5657 if append_only {
5658 el = el.append_only();
5659 }
5660 el
5661 }
5662
5663 fn parity_rows(root: &El) -> Vec<(String, Rect)> {
5665 root.children
5666 .iter()
5667 .map(|c| (c.key.clone().unwrap_or_default(), c.computed_rect))
5668 .collect()
5669 }
5670
5671 struct Frame {
5672 first: usize,
5673 count: usize,
5674 seed_offset: Option<f32>,
5675 request: Option<ScrollRequest>,
5676 }
5677
5678 fn run_parity(frames: &[Frame], pin: bool) {
5679 let mut sg = UiState::new(); let mut si = UiState::new(); for (n, f) in frames.iter().enumerate() {
5683 let viewport = Rect::new(0.0, 0.0, 300.0, 200.0);
5684 let step = |state: &mut UiState, append_only: bool| {
5685 let mut root = parity_list(f.first, f.count, append_only, pin);
5686 assign_ids(&mut root);
5687 if let Some(o) = f.seed_offset {
5688 state
5689 .scroll
5690 .offsets
5691 .insert(root.computed_id.clone().to_string(), o);
5692 }
5693 if let Some(req) = &f.request {
5694 state.push_scroll_requests(vec![req.clone()]);
5695 }
5696 layout(&mut root, state, viewport);
5697 root
5698 };
5699
5700 let root_g = step(&mut sg, false);
5701 let root_i = step(&mut si, true);
5702
5703 let rows_g = parity_rows(&root_g);
5704 let rows_i = parity_rows(&root_i);
5705 assert_eq!(
5706 rows_g.len(),
5707 rows_i.len(),
5708 "frame {n}: realized row count differs (general {} vs incremental {})",
5709 rows_g.len(),
5710 rows_i.len()
5711 );
5712 for ((kg, rg), (ki, ri)) in rows_g.iter().zip(&rows_i) {
5713 assert_eq!(kg, ki, "frame {n}: realized key order differs");
5714 assert!(
5715 (rg.x - ri.x).abs() < 1e-2
5716 && (rg.y - ri.y).abs() < 1e-2
5717 && (rg.w - ri.w).abs() < 1e-2
5718 && (rg.h - ri.h).abs() < 1e-2,
5719 "frame {n}: rect for {kg} differs: general {rg:?} vs incremental {ri:?}"
5720 );
5721 }
5722
5723 let off_g = sg.scroll.offsets.get(&*root_g.computed_id).copied();
5724 let off_i = si.scroll.offsets.get(&*root_i.computed_id).copied();
5725 assert!(
5726 (off_g.unwrap_or(0.0) - off_i.unwrap_or(0.0)).abs() < 1e-2,
5727 "frame {n}: stored offset differs: general {off_g:?} vs incremental {off_i:?}"
5728 );
5729 assert_eq!(
5730 sg.visible_range("chat"),
5731 si.visible_range("chat"),
5732 "frame {n}: visible range differs"
5733 );
5734 }
5735 }
5736
5737 #[test]
5738 fn append_only_matches_general_top_append_scroll_trim() {
5739 run_parity(
5740 &[
5741 Frame {
5743 first: 0,
5744 count: 30,
5745 seed_offset: Some(0.0),
5746 request: None,
5747 },
5748 Frame {
5750 first: 0,
5751 count: 42,
5752 seed_offset: None,
5753 request: None,
5754 },
5755 Frame {
5757 first: 0,
5758 count: 42,
5759 seed_offset: Some(400.0),
5760 request: None,
5761 },
5762 Frame {
5764 first: 8,
5765 count: 50,
5766 seed_offset: None,
5767 request: None,
5768 },
5769 Frame {
5771 first: 8,
5772 count: 62,
5773 seed_offset: None,
5774 request: None,
5775 },
5776 Frame {
5778 first: 20,
5779 count: 62,
5780 seed_offset: None,
5781 request: None,
5782 },
5783 ],
5784 false,
5785 );
5786 }
5787
5788 #[test]
5789 fn append_only_matches_general_to_row_key_request() {
5790 run_parity(
5791 &[
5792 Frame {
5793 first: 0,
5794 count: 40,
5795 seed_offset: Some(0.0),
5796 request: None,
5797 },
5798 Frame {
5800 first: 0,
5801 count: 40,
5802 seed_offset: None,
5803 request: Some(ScrollRequest::ToRowKey {
5804 list_key: "chat".into(),
5805 row_key: "m30".into(),
5806 align: ScrollAlignment::Start,
5807 }),
5808 },
5809 Frame {
5811 first: 12,
5812 count: 55,
5813 seed_offset: None,
5814 request: None,
5815 },
5816 ],
5817 false,
5818 );
5819 }
5820
5821 #[test]
5827 fn append_only_matches_general_across_width_change() {
5828 let mut sg = UiState::new();
5829 let mut si = UiState::new();
5830 let script = [
5832 (0usize, 30usize, 300.0f32),
5833 (0, 40, 300.0),
5834 (0, 40, 240.0), (6, 52, 240.0), (6, 52, 360.0), ];
5838 for (n, &(first, count, w)) in script.iter().enumerate() {
5839 let viewport = Rect::new(0.0, 0.0, w, 200.0);
5840 let step = |state: &mut UiState, append_only: bool| {
5841 let mut root = parity_list(first, count, append_only, false);
5842 assign_ids(&mut root);
5843 layout(&mut root, state, viewport);
5844 root
5845 };
5846 let rg = step(&mut sg, false);
5847 let ri = step(&mut si, true);
5848 let rows_g = parity_rows(&rg);
5849 let rows_i = parity_rows(&ri);
5850 assert_eq!(rows_g.len(), rows_i.len(), "frame {n}: row count differs");
5851 for ((kg, a), (ki, b)) in rows_g.iter().zip(&rows_i) {
5852 assert_eq!(kg, ki, "frame {n}: key order differs");
5853 assert!(
5854 (a.x - b.x).abs() < 1e-2
5855 && (a.y - b.y).abs() < 1e-2
5856 && (a.w - b.w).abs() < 1e-2
5857 && (a.h - b.h).abs() < 1e-2,
5858 "frame {n}: rect for {kg} differs: {a:?} vs {b:?}"
5859 );
5860 }
5861 assert_eq!(
5862 sg.visible_range("chat"),
5863 si.visible_range("chat"),
5864 "frame {n}: visible range differs"
5865 );
5866 }
5867 }
5868
5869 #[test]
5870 fn append_only_matches_general_pin_end_stick_to_bottom() {
5871 run_parity(
5872 &[
5873 Frame {
5874 first: 0,
5875 count: 20,
5876 seed_offset: None,
5877 request: None,
5878 },
5879 Frame {
5881 first: 0,
5882 count: 35,
5883 seed_offset: None,
5884 request: None,
5885 },
5886 Frame {
5888 first: 10,
5889 count: 50,
5890 seed_offset: None,
5891 request: None,
5892 },
5893 Frame {
5894 first: 25,
5895 count: 70,
5896 seed_offset: None,
5897 request: None,
5898 },
5899 ],
5900 true,
5901 );
5902 }
5903
5904 #[test]
5905 fn virtual_list_dyn_empty_count_realizes_no_children() {
5906 let mut root = crate::tree::virtual_list_dyn(
5907 0,
5908 50.0,
5909 |i| format!("row-{i}"),
5910 |i| crate::widgets::text::text(format!("r{i}")),
5911 );
5912 let mut state = UiState::new();
5913 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
5914 assert_eq!(root.children.len(), 0);
5915 }
5916
5917 #[test]
5918 #[should_panic(expected = "estimated_row_height > 0.0")]
5919 fn virtual_list_dyn_zero_estimate_panics() {
5920 let _ = crate::tree::virtual_list_dyn(
5921 10,
5922 0.0,
5923 |i| format!("row-{i}"),
5924 |i| crate::widgets::text::text(format!("r{i}")),
5925 );
5926 }
5927
5928 #[test]
5929 fn text_runs_constructor_shape_smoke() {
5930 let el = crate::tree::text_runs([
5931 crate::widgets::text::text("Hello, "),
5932 crate::widgets::text::text("world").bold(),
5933 crate::tree::hard_break(),
5934 crate::widgets::text::text("of text").italic(),
5935 ]);
5936 assert_eq!(el.kind, Kind::Inlines);
5937 assert_eq!(el.children.len(), 4);
5938 assert!(matches!(
5939 el.children[1].font_weight,
5940 FontWeight::Bold | FontWeight::Semibold
5941 ));
5942 assert_eq!(el.children[2].kind, Kind::HardBreak);
5943 assert!(el.children[3].text_italic);
5944 }
5945
5946 #[test]
5947 fn wrapped_text_hugs_multiline_height_from_available_width() {
5948 let mut root = column([crate::paragraph(
5949 "A longer sentence should wrap into multiple measured lines.",
5950 )])
5951 .width(Size::Fill(1.0))
5952 .height(Size::Hug);
5953
5954 let mut state = UiState::new();
5955 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
5956
5957 let child_rect = root.children[0].computed_rect;
5958 assert_eq!(child_rect.w, 180.0);
5959 assert!(
5960 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
5961 "expected multiline paragraph height, got {}",
5962 child_rect.h
5963 );
5964 }
5965
5966 #[test]
5967 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
5968 const PANEL_W: f32 = 240.0;
5979 const PADDING: f32 = 18.0;
5980 const GAP: f32 = 12.0;
5981
5982 let panel = column([
5983 crate::paragraph(
5984 "A long enough warning paragraph that it has to wrap onto a second line \
5985 inside this narrow panel.",
5986 ),
5987 crate::widgets::button::button("OK").key("ok"),
5988 ])
5989 .width(Size::Fixed(PANEL_W))
5990 .height(Size::Hug)
5991 .padding(Sides::all(PADDING))
5992 .gap(GAP)
5993 .align(Align::Stretch);
5994
5995 let mut root = crate::stack([panel])
5996 .width(Size::Fill(1.0))
5997 .height(Size::Fill(1.0));
5998 let mut state = UiState::new();
5999 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6000
6001 let panel_rect = root.children[0].computed_rect;
6002 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
6003
6004 let para_rect = root.children[0].children[0].computed_rect;
6005 let button_rect = root.children[0].children[1].computed_rect;
6006
6007 assert!(
6010 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
6011 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
6012 got h={}",
6013 para_rect.h
6014 );
6015
6016 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
6022 assert!(
6023 (bottom_padding - PADDING).abs() < 0.5,
6024 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
6025 );
6026 }
6027
6028 #[test]
6029 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
6030 const COL_W: f32 = 600.0;
6042 const GUTTER_W: f32 = 3.0;
6043
6044 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
6045 sed do eiusmod tempor incididunt ut labore et dolore magna \
6046 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
6047 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
6048
6049 let make_row = || {
6050 let gutter = El::new(Kind::Custom("gutter"))
6051 .width(Size::Fixed(GUTTER_W))
6052 .height(Size::Fill(1.0));
6053 let body = crate::paragraph(long).width(Size::Fill(1.0));
6054 crate::row([gutter, body]).width(Size::Fill(1.0))
6055 };
6056
6057 let mut root = column([make_row(), make_row()])
6058 .width(Size::Fixed(COL_W))
6059 .height(Size::Hug)
6060 .align(Align::Stretch);
6061 let mut state = UiState::new();
6062 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
6063
6064 let row0_rect = root.children[0].computed_rect;
6065 let row1_rect = root.children[1].computed_rect;
6066 let para0_rect = root.children[0].children[1].computed_rect;
6067
6068 let line_height = crate::tokens::TEXT_SM.line_height;
6073 assert!(
6074 para0_rect.h > line_height * 1.5,
6075 "paragraph should wrap to multiple lines at ~597px wide; \
6076 got h={} (line_height={})",
6077 para0_rect.h,
6078 line_height,
6079 );
6080 assert!(
6081 row0_rect.h > line_height * 1.5,
6082 "row 0 should accommodate the wrapped paragraph height; \
6083 got h={} (line_height={})",
6084 row0_rect.h,
6085 line_height,
6086 );
6087
6088 assert!(
6090 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
6091 "row 1 starts at y={} but row 0 occupies y={}..{}",
6092 row1_rect.y,
6093 row0_rect.y,
6094 row0_rect.y + row0_rect.h,
6095 );
6096 }
6097
6098 #[test]
6103 fn min_width_floors_resolved_cross_axis_size() {
6104 let mut root = column([crate::widgets::text::text("hi")
6105 .width(Size::Fixed(40.0))
6106 .height(Size::Fixed(20.0))
6107 .min_width(120.0)])
6108 .align(Align::Start)
6109 .width(Size::Fixed(500.0))
6110 .height(Size::Fixed(200.0));
6111 let mut state = UiState::new();
6112 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6113 let child_rect = root.children[0].computed_rect;
6114 assert!(
6115 (child_rect.w - 120.0).abs() < 0.5,
6116 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
6117 child_rect.w,
6118 );
6119 }
6120
6121 #[test]
6124 fn max_width_caps_fill_child() {
6125 let mut root = crate::row([crate::widgets::text::text("body")
6126 .width(Size::Fill(1.0))
6127 .height(Size::Fixed(20.0))
6128 .max_width(160.0)])
6129 .width(Size::Fixed(800.0))
6130 .height(Size::Fixed(40.0));
6131 let mut state = UiState::new();
6132 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
6133 let child_rect = root.children[0].computed_rect;
6134 assert!(
6135 (child_rect.w - 160.0).abs() < 0.5,
6136 "expected Fill child capped at 160, got w={}",
6137 child_rect.w,
6138 );
6139 }
6140
6141 #[test]
6145 fn ch_unit_reserves_constant_digit_width() {
6146 use crate::widgets::text::text;
6147 let mut root = column([
6148 text("8")
6149 .tabular_numerals()
6150 .width(Size::Ch(4.0))
6151 .height(Size::Fixed(20.0)),
6152 text("123456")
6153 .tabular_numerals()
6154 .width(Size::Ch(4.0))
6155 .height(Size::Fixed(20.0)),
6156 text("8")
6157 .tabular_numerals()
6158 .width(Size::Ch(2.0))
6159 .height(Size::Fixed(20.0)),
6160 ]);
6161 let mut state = UiState::new();
6162 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6163 let four_a = root.children[0].computed_rect.w;
6164 let four_b = root.children[1].computed_rect.w;
6165 let two = root.children[2].computed_rect.w;
6166 assert!(four_a > 0.0);
6167 assert!(
6169 (four_a - four_b).abs() < 0.01,
6170 "Ch(4) width must not depend on the text: {four_a} vs {four_b}"
6171 );
6172 assert!(
6174 (four_a - 2.0 * two).abs() < 0.5,
6175 "Ch(4) should be twice Ch(2): {four_a} vs 2×{two}"
6176 );
6177 }
6178
6179 #[test]
6182 fn min_width_wins_over_max_width_when_conflicting() {
6183 let mut root = column([crate::widgets::text::text("x")
6184 .width(Size::Fixed(50.0))
6185 .height(Size::Fixed(20.0))
6186 .max_width(80.0)
6187 .min_width(120.0)]);
6188 let mut state = UiState::new();
6189 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
6190 let child_rect = root.children[0].computed_rect;
6191 assert!(
6192 (child_rect.w - 120.0).abs() < 0.5,
6193 "expected min_width (120) to win over max_width (80), got w={}",
6194 child_rect.w,
6195 );
6196 }
6197
6198 #[test]
6202 fn min_height_floors_hug_column_inside_fixed_parent() {
6203 let inner = column([crate::widgets::text::text("a")
6204 .width(Size::Fixed(40.0))
6205 .height(Size::Fixed(20.0))])
6206 .width(Size::Fixed(80.0))
6207 .height(Size::Hug)
6208 .min_height(200.0);
6209 let mut root = column([inner])
6210 .align(Align::Start)
6211 .width(Size::Fixed(800.0))
6212 .height(Size::Fixed(600.0));
6213 let mut state = UiState::new();
6214 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6215 let inner_rect = root.children[0].computed_rect;
6216 assert!(
6217 (inner_rect.h - 200.0).abs() < 0.5,
6218 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
6219 inner_rect.h,
6220 );
6221 }
6222
6223 #[test]
6232 fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
6233 let mut root = crate::row([
6237 column([crate::widgets::text::paragraph(
6238 "A long enough description that must wrap to two lines at 148px",
6239 )])
6240 .width(Size::Fill(1.0)),
6241 crate::widgets::text::text("ok")
6242 .width(Size::Fixed(40.0))
6243 .height(Size::Fixed(20.0)),
6244 ])
6245 .gap(12.0)
6246 .align(Align::Center)
6247 .width(Size::Fixed(200.0));
6248 let mut state = UiState::new();
6249 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
6250 let col_rect = root.children[0].computed_rect;
6252 let para_rect = root.children[0].children[0].computed_rect;
6253 assert!(
6254 (col_rect.h - para_rect.h).abs() < 0.5,
6255 "column height ({}) should track its wrapped child's height ({})",
6256 col_rect.h,
6257 para_rect.h,
6258 );
6259 }
6260
6261 #[test]
6265 fn aspect_on_column_main_axis_derives_from_cross() {
6266 let mut root = column([El::new(Kind::Group)
6267 .width(Size::Fill(1.0))
6268 .height(Size::Aspect(0.5))])
6269 .width(Size::Fixed(200.0))
6270 .height(Size::Fixed(400.0));
6271 let mut state = UiState::new();
6272 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
6273 let r = root.children[0].computed_rect;
6274 assert!(
6275 (r.w - 200.0).abs() < 0.5,
6276 "expected w≈200 (Fill), got {}",
6277 r.w,
6278 );
6279 assert!(
6280 (r.h - 100.0).abs() < 0.5,
6281 "expected h≈100 (Aspect 0.5 of 200), got {}",
6282 r.h,
6283 );
6284 }
6285
6286 #[test]
6290 fn aspect_height_pushes_siblings_in_column() {
6291 let mut root = column([
6292 El::new(Kind::Group)
6293 .width(Size::Fill(1.0))
6294 .height(Size::Aspect(0.25)),
6295 crate::widgets::text::text("caption")
6296 .width(Size::Fixed(40.0))
6297 .height(Size::Fixed(20.0)),
6298 ])
6299 .width(Size::Fixed(400.0))
6300 .height(Size::Fixed(500.0));
6301 let mut state = UiState::new();
6302 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
6303 let img = root.children[0].computed_rect;
6304 let cap = root.children[1].computed_rect;
6305 assert!(
6306 (img.h - 100.0).abs() < 0.5,
6307 "expected aspect-derived height ≈100, got {}",
6308 img.h,
6309 );
6310 assert!(
6311 (cap.y - 100.0).abs() < 0.5,
6312 "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
6313 cap.y,
6314 );
6315 }
6316
6317 #[test]
6321 fn aspect_on_row_cross_axis_derives_from_main() {
6322 let mut root = crate::row([El::new(Kind::Group)
6323 .height(Size::Fill(1.0))
6324 .width(Size::Aspect(2.0))])
6325 .width(Size::Fixed(800.0))
6326 .height(Size::Fixed(200.0));
6327 let mut state = UiState::new();
6328 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
6329 let r = root.children[0].computed_rect;
6330 assert!(
6331 (r.h - 200.0).abs() < 0.5,
6332 "expected h≈200 (Fill), got {}",
6333 r.h,
6334 );
6335 assert!(
6336 (r.w - 400.0).abs() < 0.5,
6337 "expected w≈400 (Aspect 2.0 of 200), got {}",
6338 r.w,
6339 );
6340 }
6341
6342 #[test]
6345 fn aspect_on_both_axes_falls_back_to_intrinsic() {
6346 let mut root = column([crate::widgets::text::text("hi")
6347 .width(Size::Aspect(1.0))
6348 .height(Size::Aspect(1.0))])
6349 .width(Size::Fixed(200.0))
6350 .height(Size::Fixed(200.0));
6351 let mut state = UiState::new();
6352 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
6353 let r = root.children[0].computed_rect;
6354 assert!(
6355 r.w > 0.0 && r.h > 0.0,
6356 "expected finite size for both-Aspect fallback, got {}x{}",
6357 r.w,
6358 r.h,
6359 );
6360 }
6361
6362 #[test]
6366 fn aspect_respects_min_and_max_on_derived_axis() {
6367 let mut root = column([column([El::new(Kind::Group)
6371 .width(Size::Fill(1.0))
6372 .height(Size::Aspect(1.0))
6373 .max_height(120.0)])
6374 .width(Size::Hug)
6375 .height(Size::Hug)])
6376 .width(Size::Fixed(400.0))
6377 .height(Size::Fixed(600.0));
6378 let mut state = UiState::new();
6379 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6380 let panel = root.children[0].computed_rect;
6381 let img = root.children[0].children[0].computed_rect;
6382 assert!(
6383 (img.h - 120.0).abs() < 0.5,
6384 "max_height should clamp aspect-derived height to 120, got {}",
6385 img.h,
6386 );
6387 assert!(
6388 (panel.h - 120.0).abs() < 0.5,
6389 "hugging panel should match clamped child (120), got {}",
6390 panel.h,
6391 );
6392
6393 let mut root = column([column([El::new(Kind::Group)
6396 .width(Size::Fill(1.0))
6397 .height(Size::Aspect(0.1))
6398 .min_height(200.0)])
6399 .width(Size::Hug)
6400 .height(Size::Hug)])
6401 .width(Size::Fixed(400.0))
6402 .height(Size::Fixed(600.0));
6403 let mut state = UiState::new();
6404 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
6405 let panel = root.children[0].computed_rect;
6406 let img = root.children[0].children[0].computed_rect;
6407 assert!(
6408 (img.h - 200.0).abs() < 0.5,
6409 "min_height should bump aspect-derived height to 200, got {}",
6410 img.h,
6411 );
6412 assert!(
6413 (panel.h - 200.0).abs() < 0.5,
6414 "hugging panel should match bumped child (200), got {}",
6415 panel.h,
6416 );
6417 }
6418
6419 #[test]
6422 fn aspect_basis_is_clamped_before_deriving() {
6423 let mut root = column([El::new(Kind::Group)
6429 .width(Size::Fill(1.0))
6430 .height(Size::Aspect(0.5))
6431 .max_width(100.0)])
6432 .width(Size::Fixed(400.0))
6433 .height(Size::Fixed(400.0));
6434 let mut state = UiState::new();
6435 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6436 let img = root.children[0].computed_rect;
6437 assert!(
6438 (img.w - 100.0).abs() < 0.5,
6439 "max_width should cap Fill width at 100, got {}",
6440 img.w,
6441 );
6442 assert!(
6443 (img.h - 50.0).abs() < 0.5,
6444 "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
6445 img.h,
6446 );
6447 }
6448
6449 #[test]
6455 fn hug_column_around_fill_aspect_child_does_not_overflow() {
6456 let mut root = column([column([El::new(Kind::Group)
6463 .width(Size::Fill(1.0))
6464 .height(Size::Aspect(0.5))])
6465 .width(Size::Hug)
6466 .height(Size::Hug)])
6467 .width(Size::Fixed(400.0))
6468 .height(Size::Fixed(400.0));
6469 let mut state = UiState::new();
6470 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6471 let panel = root.children[0].computed_rect;
6472 let img = root.children[0].children[0].computed_rect;
6473 assert!(
6474 (panel.h - 200.0).abs() < 0.5,
6475 "hugging panel should hug to aspect-derived height 200, got {}",
6476 panel.h,
6477 );
6478 assert!(
6479 (img.h - 200.0).abs() < 0.5,
6480 "image should layout to height 200, got {}",
6481 img.h,
6482 );
6483 assert!(
6484 img.bottom() <= panel.bottom() + 0.5,
6485 "image (bottom={}) must fit within hugging panel (bottom={})",
6486 img.bottom(),
6487 panel.bottom(),
6488 );
6489 }
6490
6491 #[test]
6495 fn hugging_parent_sees_aspect_corrected_intrinsic() {
6496 let mut root = column([column([El::new(Kind::Group)
6500 .width(Size::Fixed(80.0))
6501 .height(Size::Aspect(0.5))])
6502 .width(Size::Hug)
6503 .height(Size::Hug)])
6504 .width(Size::Fixed(400.0))
6505 .height(Size::Fixed(400.0))
6506 .align(Align::Start);
6507 let mut state = UiState::new();
6508 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
6509 let hugger = root.children[0].computed_rect;
6510 assert!(
6511 (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
6512 "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
6513 hugger.w,
6514 hugger.h,
6515 );
6516 }
6517
6518 #[test]
6520 fn max_height_caps_overlay_child_below_intrinsic() {
6521 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
6524 .width(Size::Fixed(40.0))
6525 .height(Size::Fixed(300.0))])
6526 .width(Size::Hug)
6527 .height(Size::Hug)
6528 .max_height(100.0)])
6529 .width(Size::Fixed(600.0))
6530 .height(Size::Fixed(600.0));
6531 let mut state = UiState::new();
6532 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
6533 let child_rect = root.children[0].computed_rect;
6534 assert!(
6535 (child_rect.h - 100.0).abs() < 0.5,
6536 "expected child height capped at 100, got h={}",
6537 child_rect.h,
6538 );
6539 }
6540
6541 #[test]
6545 fn user_resizable_publishes_trailing_edge_band() {
6546 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6547 let mut root = crate::tree::row([
6548 column(Vec::<El>::new())
6549 .key("nav")
6550 .user_resizable()
6551 .width(Size::Fixed(200.0))
6552 .min_width(120.0)
6553 .max_width(420.0),
6554 column(Vec::<El>::new()).width(Size::Fill(1.0)),
6555 ])
6556 .height(Size::Fixed(400.0));
6557 let mut state = UiState::new();
6558 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6559
6560 assert_eq!(state.resize.bands.len(), 1);
6561 let band = &state.resize.bands[0];
6562 assert_eq!(band.id, "nav");
6563 assert_eq!(band.key.as_deref(), Some("nav"));
6564 assert_eq!(band.sign, 1.0, "trailing edge: drag-right grows");
6565 assert!((band.current - 200.0).abs() < 0.5);
6566 assert_eq!((band.min, band.max), (120.0, 420.0));
6567 assert!((band.band.x - (200.0 - T / 2.0)).abs() < 0.5);
6569 assert!((band.band.w - T).abs() < 0.5);
6570 assert!((band.band.h - 400.0).abs() < 0.5);
6571 }
6572
6573 #[test]
6578 fn user_resizable_last_child_gets_leading_edge_band() {
6579 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6580 let mut root = crate::tree::row([
6581 column(Vec::<El>::new()).width(Size::Fill(1.0)),
6582 column(Vec::<El>::new())
6583 .key("inspector")
6584 .user_resizable()
6585 .width(Size::Fixed(240.0)),
6586 ])
6587 .height(Size::Fixed(400.0));
6588 let mut state = UiState::new();
6589 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6590
6591 assert_eq!(state.resize.bands.len(), 1);
6592 let band = &state.resize.bands[0];
6593 assert_eq!(band.sign, -1.0, "leading edge: drag-left grows");
6594 assert!((band.band.x - (560.0 - T / 2.0)).abs() < 0.5);
6596 assert_eq!(band.min, 0.0);
6597 assert!(
6598 (band.max - 800.0).abs() < 0.5,
6599 "no max_width → capped at the parent's inner extent, got {}",
6600 band.max,
6601 );
6602 }
6603
6604 #[test]
6608 fn user_resizable_override_applies_and_clamps() {
6609 let build = || {
6610 crate::tree::row([
6611 column(Vec::<El>::new())
6612 .key("nav")
6613 .user_resizable()
6614 .width(Size::Fixed(200.0))
6615 .min_width(120.0)
6616 .max_width(420.0),
6617 column(Vec::<El>::new()).key("main").width(Size::Fill(1.0)),
6618 ])
6619 .height(Size::Fixed(400.0))
6620 };
6621 let mut state = UiState::new();
6622 state.set_user_size("nav", 300.0);
6623 let mut root = build();
6624 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6625 let nav = state.rect_of_key("nav").unwrap();
6626 assert!((nav.w - 300.0).abs() < 0.5, "override wins, got {}", nav.w);
6627 let main = state.rect_of_key("main").unwrap();
6628 assert!(
6629 (main.w - 500.0).abs() < 0.5,
6630 "the Fill sibling absorbs the change, got {}",
6631 main.w,
6632 );
6633
6634 state.set_user_size("nav", 9999.0);
6636 let mut root = build();
6637 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6638 assert!((state.rect_of_key("nav").unwrap().w - 420.0).abs() < 0.5);
6639
6640 state.clear_user_size("nav");
6642 let mut root = build();
6643 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 400.0));
6644 assert!((state.rect_of_key("nav").unwrap().w - 200.0).abs() < 0.5);
6645 }
6646
6647 #[test]
6651 fn user_resizable_in_column_resizes_height() {
6652 use crate::state::resize::RESIZE_BAND_THICKNESS as T;
6653 let mut root = column([
6654 crate::tree::row(Vec::<El>::new())
6655 .key("topbar")
6656 .user_resizable()
6657 .height(Size::Fixed(100.0))
6658 .min_height(40.0),
6659 crate::tree::row(Vec::<El>::new()).height(Size::Fill(1.0)),
6660 ])
6661 .width(Size::Fixed(800.0));
6662 let mut state = UiState::new();
6663 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6664
6665 assert_eq!(state.resize.bands.len(), 1);
6666 let band = &state.resize.bands[0];
6667 assert!((band.band.y - (100.0 - T / 2.0)).abs() < 0.5);
6668 assert!((band.band.w - 800.0).abs() < 0.5);
6669 assert_eq!(band.min, 40.0);
6670 assert!((band.current - 100.0).abs() < 0.5);
6671
6672 state.set_user_size("topbar", 250.0);
6673 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
6674 assert!((state.rect_of_key("topbar").unwrap().h - 250.0).abs() < 0.5);
6675 }
6676}