1use std::cell::RefCell;
35use std::sync::Arc;
36
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::scroll::{ScrollAlignment, ScrollRequest};
40use crate::state::{ScrollAnchor, UiState, VirtualAnchor};
41use crate::text::metrics as text_metrics;
42use crate::tree::*;
43
44#[derive(Clone)]
74pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
75
76impl LayoutFn {
77 pub fn new<F>(f: F) -> Self
78 where
79 F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
80 {
81 LayoutFn(Arc::new(f))
82 }
83}
84
85impl std::fmt::Debug for LayoutFn {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str("LayoutFn(<fn>)")
88 }
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct LayoutIntrinsicCacheStats {
93 pub hits: u64,
94 pub misses: u64,
95}
96
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub struct LayoutPruneStats {
99 pub subtrees: u64,
100 pub nodes: u64,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Hash)]
104struct IntrinsicCacheKey {
105 computed_id: String,
106 available_width_bits: Option<u32>,
107}
108
109#[derive(Default)]
110struct IntrinsicCache {
111 measurements: FxHashMap<IntrinsicCacheKey, (f32, f32)>,
112 stats: LayoutIntrinsicCacheStats,
113 prune: LayoutPruneStats,
114}
115
116thread_local! {
117 static INTRINSIC_CACHE: RefCell<Option<IntrinsicCache>> = const { RefCell::new(None) };
118 static LAST_INTRINSIC_CACHE_STATS: RefCell<LayoutIntrinsicCacheStats> =
119 const { RefCell::new(LayoutIntrinsicCacheStats { hits: 0, misses: 0 }) };
120 static LAST_PRUNE_STATS: RefCell<LayoutPruneStats> =
121 const { RefCell::new(LayoutPruneStats { subtrees: 0, nodes: 0 }) };
122}
123
124struct IntrinsicCacheGuard {
125 previous: Option<IntrinsicCache>,
126}
127
128impl Drop for IntrinsicCacheGuard {
129 fn drop(&mut self) {
130 INTRINSIC_CACHE.with(|cell| {
131 cell.replace(self.previous.take());
132 });
133 }
134}
135
136fn with_intrinsic_cache(f: impl FnOnce()) {
137 let previous = INTRINSIC_CACHE.with(|cell| cell.replace(Some(IntrinsicCache::default())));
138 let mut guard = IntrinsicCacheGuard { previous };
139 f();
140 let finished = INTRINSIC_CACHE.with(|cell| cell.replace(guard.previous.take()));
141 if let Some(cache) = finished {
142 LAST_INTRINSIC_CACHE_STATS.with(|stats| {
143 *stats.borrow_mut() = cache.stats;
144 });
145 LAST_PRUNE_STATS.with(|stats| {
146 *stats.borrow_mut() = cache.prune;
147 });
148 }
149 std::mem::forget(guard);
150}
151
152pub fn take_intrinsic_cache_stats() -> LayoutIntrinsicCacheStats {
153 LAST_INTRINSIC_CACHE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
154}
155
156pub fn take_prune_stats() -> LayoutPruneStats {
157 LAST_PRUNE_STATS.with(|stats| std::mem::take(&mut *stats.borrow_mut()))
158}
159
160#[derive(Clone, Debug)]
183pub enum VirtualMode {
184 Fixed { row_height: f32 },
186 Dynamic { estimated_row_height: f32 },
190}
191
192#[derive(Clone, Copy, Debug, PartialEq)]
196pub enum VirtualAnchorPolicy {
197 ViewportFraction { y_fraction: f32 },
200 FirstVisible,
203 LastVisible,
206}
207
208impl Default for VirtualAnchorPolicy {
209 fn default() -> Self {
210 Self::ViewportFraction { y_fraction: 0.25 }
211 }
212}
213
214#[derive(Clone)]
215#[non_exhaustive]
216pub struct VirtualItems {
217 pub count: usize,
218 pub mode: VirtualMode,
219 pub anchor_policy: VirtualAnchorPolicy,
220 pub row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
221 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
222}
223
224impl VirtualItems {
225 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
226 where
227 F: Fn(usize) -> El + Send + Sync + 'static,
228 {
229 assert!(
230 row_height > 0.0,
231 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
232 );
233 VirtualItems {
234 count,
235 mode: VirtualMode::Fixed { row_height },
236 anchor_policy: VirtualAnchorPolicy::default(),
237 row_key: Arc::new(|i| i.to_string()),
238 build_row: Arc::new(build_row),
239 }
240 }
241
242 pub fn new_dyn<K, F>(count: usize, estimated_row_height: f32, row_key: K, build_row: F) -> Self
243 where
244 K: Fn(usize) -> String + Send + Sync + 'static,
245 F: Fn(usize) -> El + Send + Sync + 'static,
246 {
247 assert!(
248 estimated_row_height > 0.0,
249 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
250 );
251 VirtualItems {
252 count,
253 mode: VirtualMode::Dynamic {
254 estimated_row_height,
255 },
256 anchor_policy: VirtualAnchorPolicy::default(),
257 row_key: Arc::new(row_key),
258 build_row: Arc::new(build_row),
259 }
260 }
261
262 pub fn anchor_policy(mut self, policy: VirtualAnchorPolicy) -> Self {
263 self.anchor_policy = policy;
264 self
265 }
266}
267
268impl std::fmt::Debug for VirtualItems {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.debug_struct("VirtualItems")
271 .field("count", &self.count)
272 .field("mode", &self.mode)
273 .field("anchor_policy", &self.anchor_policy)
274 .field("row_key", &"<fn>")
275 .field("build_row", &"<fn>")
276 .finish()
277 }
278}
279
280#[non_exhaustive]
285pub struct LayoutCtx<'a> {
286 pub container: Rect,
290 pub children: &'a [El],
293 pub measure: &'a dyn Fn(&El) -> (f32, f32),
297 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
304 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
310}
311
312pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
321 {
322 crate::profile_span!("layout::assign_ids");
323 assign_id(root, "root");
324 }
325 layout_post_assign(root, ui_state, viewport);
326}
327
328pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
334 with_intrinsic_cache(|| {
335 {
336 crate::profile_span!("layout::root_setup");
337 ui_state
338 .layout
339 .computed_rects
340 .insert(root.computed_id.clone(), viewport);
341 rebuild_key_index(root, ui_state);
342 ui_state.scroll.metrics.clear();
346 ui_state.scroll.thumb_rects.clear();
347 ui_state.scroll.thumb_tracks.clear();
348 }
349 crate::profile_span!("layout::children");
350 layout_children(root, viewport, ui_state);
351 });
352}
353
354pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
361 let role = role_token(&child.kind);
362 let suffix = match (&child.key, role) {
363 (Some(k), r) => format!("{r}[{k}]"),
364 (None, r) => format!("{r}.{child_index}"),
365 };
366 assign_id(child, &format!("{parent_id}.{suffix}"));
367}
368
369fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
374 ui_state.layout.key_index.clear();
375 fn visit(node: &El, index: &mut rustc_hash::FxHashMap<String, String>) {
376 if let Some(key) = &node.key {
377 index
378 .entry(key.clone())
379 .or_insert_with(|| node.computed_id.clone());
380 }
381 for c in &node.children {
382 visit(c, index);
383 }
384 }
385 visit(root, &mut ui_state.layout.key_index);
386}
387
388pub fn assign_ids(root: &mut El) {
392 assign_id(root, "root");
393}
394
395fn assign_id(node: &mut El, path: &str) {
396 node.computed_id = path.to_string();
397 for (i, c) in node.children.iter_mut().enumerate() {
398 let role = role_token(&c.kind);
399 let suffix = match (&c.key, role) {
400 (Some(k), r) => format!("{r}[{k}]"),
401 (None, r) => format!("{r}.{i}"),
402 };
403 let child_path = format!("{path}.{suffix}");
404 assign_id(c, &child_path);
405 }
406}
407
408fn role_token(k: &Kind) -> &'static str {
409 match k {
410 Kind::Group => "group",
411 Kind::Card => "card",
412 Kind::Button => "button",
413 Kind::Badge => "badge",
414 Kind::Text => "text",
415 Kind::Heading => "heading",
416 Kind::Spacer => "spacer",
417 Kind::Divider => "divider",
418 Kind::Overlay => "overlay",
419 Kind::Scrim => "scrim",
420 Kind::Modal => "modal",
421 Kind::Scroll => "scroll",
422 Kind::VirtualList => "virtual_list",
423 Kind::Inlines => "inlines",
424 Kind::HardBreak => "hard_break",
425 Kind::Math => "math",
426 Kind::Image => "image",
427 Kind::Surface => "surface",
428 Kind::Vector => "vector",
429 Kind::Scene3D => "scene3d",
430 Kind::Custom(name) => name,
431 }
432}
433
434fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
435 if matches!(node.kind, Kind::Inlines) {
436 for c in &mut node.children {
444 ui_state.layout.computed_rects.insert(
445 c.computed_id.clone(),
446 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
447 );
448 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
452 }
453 return;
454 }
455 if let Some(items) = node.virtual_items.clone() {
456 layout_virtual(node, node_rect, items, ui_state);
457 return;
458 }
459 if let Some(layout_fn) = node.layout_override.clone() {
460 layout_custom(node, node_rect, layout_fn, ui_state);
461 if node.scrollable {
462 apply_scroll_offset(node, node_rect, ui_state);
463 }
464 return;
465 }
466 match node.axis {
467 Axis::Overlay => {
468 let inner = node_rect.inset(node.padding);
469 for c in &mut node.children {
470 let c_rect = overlay_rect(c, inner, node.align, node.justify);
471 ui_state
472 .layout
473 .computed_rects
474 .insert(c.computed_id.clone(), c_rect);
475 layout_children(c, c_rect, ui_state);
476 }
477 }
478 Axis::Column => layout_axis(node, node_rect, true, ui_state),
479 Axis::Row => layout_axis(node, node_rect, false, ui_state),
480 }
481 if node.scrollable {
482 apply_scroll_offset(node, node_rect, ui_state);
483 }
484}
485
486fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
487 let inner = node_rect.inset(node.padding);
488 let measure = |c: &El| intrinsic(c);
489 let key_index = &ui_state.layout.key_index;
494 let computed_rects = &ui_state.layout.computed_rects;
495 let rect_of_key = |key: &str| -> Option<Rect> {
496 let id = key_index.get(key)?;
497 computed_rects.get(id).copied()
498 };
499 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
500 let rects = (layout_fn.0)(LayoutCtx {
501 container: inner,
502 children: &node.children,
503 measure: &measure,
504 rect_of_key: &rect_of_key,
505 rect_of_id: &rect_of_id,
506 });
507 assert_eq!(
508 rects.len(),
509 node.children.len(),
510 "LayoutFn for {:?} returned {} rects for {} children",
511 node.computed_id,
512 rects.len(),
513 node.children.len(),
514 );
515 for (c, c_rect) in node.children.iter_mut().zip(rects) {
516 ui_state
517 .layout
518 .computed_rects
519 .insert(c.computed_id.clone(), c_rect);
520 layout_children(c, c_rect, ui_state);
521 }
522}
523
524fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
530 let inner = node_rect.inset(node.padding);
531 match items.mode {
532 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
533 node,
534 inner,
535 items.count,
536 row_height,
537 items.build_row,
538 ui_state,
539 ),
540 VirtualMode::Dynamic {
541 estimated_row_height,
542 } => layout_virtual_dynamic(
543 node,
544 inner,
545 items.count,
546 estimated_row_height,
547 DynamicVirtualFns {
548 anchor_policy: items.anchor_policy,
549 row_key: items.row_key,
550 build_row: items.build_row,
551 },
552 ui_state,
553 ),
554 }
555}
556
557fn resolve_scroll_requests<F, K>(
567 node: &El,
568 inner: Rect,
569 count: usize,
570 row_extent: F,
571 row_for_key: K,
572 ui_state: &mut UiState,
573) -> bool
574where
575 F: Fn(usize) -> (f32, f32),
576 K: Fn(&str) -> Option<usize>,
577{
578 if ui_state.scroll.pending_requests.is_empty() {
579 return false;
580 }
581 let Some(key) = node.key.as_deref() else {
582 return false;
583 };
584 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
585 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
586 pending.into_iter().partition(|req| match req {
587 ScrollRequest::ToRow { list_key, .. } => list_key == key,
588 ScrollRequest::ToRowKey { list_key, .. } => list_key == key,
589 ScrollRequest::EnsureVisible { .. } => false,
592 });
593 ui_state.scroll.pending_requests = remaining;
594
595 let mut wrote = false;
596 for req in matched {
597 let (row, align) = match req {
598 ScrollRequest::ToRow { row, align, .. } => (row, align),
599 ScrollRequest::ToRowKey { row_key, align, .. } => {
600 let Some(row) = row_for_key(&row_key) else {
601 continue;
602 };
603 (row, align)
604 }
605 ScrollRequest::EnsureVisible { .. } => continue,
606 };
607 if row >= count {
608 continue;
609 }
610 let (row_top, row_h) = row_extent(row);
611 let row_bottom = row_top + row_h;
612 let viewport_h = inner.h;
613 let current = ui_state
614 .scroll
615 .offsets
616 .get(&node.computed_id)
617 .copied()
618 .unwrap_or(0.0);
619 let new_offset = match align {
620 ScrollAlignment::Start => row_top,
621 ScrollAlignment::End => row_bottom - viewport_h,
622 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
623 ScrollAlignment::Visible => {
624 if row_top < current {
625 row_top
626 } else if row_bottom > current + viewport_h {
627 row_bottom - viewport_h
628 } else {
629 continue;
630 }
631 }
632 };
633 ui_state
634 .scroll
635 .offsets
636 .insert(node.computed_id.clone(), new_offset);
637 wrote = true;
638 }
639 wrote
640}
641
642fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
645 let max_offset = (total_h - inner.h).max(0.0);
646 let stored = ui_state
647 .scroll
648 .offsets
649 .get(&node.computed_id)
650 .copied()
651 .unwrap_or(0.0);
652 let stored = resolve_pin(node, stored, max_offset, ui_state);
653 let offset = stored.clamp(0.0, max_offset);
654 ui_state
655 .scroll
656 .offsets
657 .insert(node.computed_id.clone(), offset);
658 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
659 offset
660}
661
662fn write_virtual_scroll_metrics(
663 node: &El,
664 inner: Rect,
665 total_h: f32,
666 max_offset: f32,
667 offset: f32,
668 ui_state: &mut UiState,
669) {
670 ui_state.scroll.metrics.insert(
671 node.computed_id.clone(),
672 crate::state::ScrollMetrics {
673 viewport_h: inner.h,
674 content_h: total_h,
675 max_offset,
676 },
677 );
678 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
679}
680
681fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
685 let role = role_token(&child.kind);
686 let suffix = match (&child.key, role) {
687 (Some(k), r) => format!("{r}[{k}]"),
688 (None, r) => format!("{r}.{global_i}"),
689 };
690 assign_id(child, &format!("{parent_id}.{suffix}"));
691}
692
693fn layout_virtual_fixed(
694 node: &mut El,
695 inner: Rect,
696 count: usize,
697 row_height: f32,
698 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
699 ui_state: &mut UiState,
700) {
701 let gap = node.gap.max(0.0);
702 let pitch = row_height + gap;
703 let total_h = virtual_total_height(count, count as f32 * row_height, gap);
704 resolve_scroll_requests(
705 node,
706 inner,
707 count,
708 |i| (i as f32 * pitch, row_height),
709 |row_key| row_key.parse::<usize>().ok().filter(|row| *row < count),
710 ui_state,
711 );
712 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
713
714 if count == 0 {
715 node.children.clear();
716 return;
717 }
718
719 let start = (offset / pitch).floor() as usize;
723 let end = ((((offset + inner.h) / pitch).ceil() as usize) + 1).min(count);
724
725 let mut realized: Vec<El> = Vec::new();
726 for global_i in start..end {
727 let row_top = global_i as f32 * pitch;
728 if row_top >= offset + inner.h || row_top + row_height <= offset {
729 continue;
730 }
731 let mut child = (build_row)(global_i);
732 assign_virtual_row_id(&mut child, &node.computed_id, global_i);
733
734 let row_y = inner.y + row_top - offset;
735 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
736 ui_state
737 .layout
738 .computed_rects
739 .insert(child.computed_id.clone(), c_rect);
740 layout_children(&mut child, c_rect, ui_state);
741 realized.push(child);
742 }
743 node.children = realized;
744}
745
746fn layout_virtual_dynamic(
747 node: &mut El,
748 inner: Rect,
749 count: usize,
750 estimated_row_height: f32,
751 fns: DynamicVirtualFns,
752 ui_state: &mut UiState,
753) {
754 let gap = node.gap.max(0.0);
755 let width_bucket = virtual_width_bucket(inner.w);
756 let row_keys = (0..count).map(|i| (fns.row_key)(i)).collect::<Vec<_>>();
757 prune_dynamic_measurements(node, &row_keys, ui_state);
758
759 if count == 0 {
760 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
761 let offset = write_virtual_scroll_state(node, inner, 0.0, ui_state);
762 debug_assert_eq!(offset, 0.0);
763 node.children.clear();
764 return;
765 }
766
767 let mut row_heights = dynamic_row_heights(
768 node,
769 &row_keys,
770 width_bucket,
771 estimated_row_height,
772 ui_state,
773 );
774
775 let has_request = node.key.as_deref().is_some_and(|k| {
781 ui_state.scroll.pending_requests.iter().any(|r| match r {
782 ScrollRequest::ToRow { list_key, .. } => list_key == k,
783 ScrollRequest::ToRowKey { list_key, .. } => list_key == k,
784 ScrollRequest::EnsureVisible { .. } => false,
785 })
786 });
787 let mut request_wrote = false;
788 if has_request {
789 request_wrote = resolve_scroll_requests(
790 node,
791 inner,
792 count,
793 |target| {
794 (
795 dynamic_row_top(&row_heights, gap, target),
796 row_heights[target],
797 )
798 },
799 |row_key| row_keys.iter().position(|key| key == row_key),
800 ui_state,
801 );
802 }
803
804 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
805 let max_offset = (total_h - inner.h).max(0.0);
806 let stored = ui_state
807 .scroll
808 .offsets
809 .get(&node.computed_id)
810 .copied()
811 .unwrap_or(0.0);
812 let pin_active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
813 let provisional_offset = if pin_active {
814 match node.pin_policy {
815 crate::tree::PinPolicy::End => max_offset,
816 crate::tree::PinPolicy::Start => 0.0,
817 crate::tree::PinPolicy::None => unreachable!(),
818 }
819 } else if request_wrote {
820 stored
821 } else {
822 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
823 .unwrap_or(stored)
824 }
825 .clamp(0.0, max_offset);
826
827 let (measure_start, _, measure_end) =
828 dynamic_visible_range(&row_heights, gap, provisional_offset, inner.h);
829 measure_dynamic_range(
830 node,
831 DynamicRangeCtx {
832 inner,
833 row_keys: &row_keys,
834 width_bucket,
835 build_row: &fns.build_row,
836 },
837 measure_start,
838 measure_end,
839 ui_state,
840 );
841
842 row_heights = dynamic_row_heights(
843 node,
844 &row_keys,
845 width_bucket,
846 estimated_row_height,
847 ui_state,
848 );
849 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
850 let max_offset = (total_h - inner.h).max(0.0);
851 let stored = ui_state
852 .scroll
853 .offsets
854 .get(&node.computed_id)
855 .copied()
856 .unwrap_or(0.0);
857 let pin_resolved = resolve_pin(node, stored, max_offset, ui_state);
858 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
859 && ui_state
860 .scroll
861 .pin_active
862 .get(&node.computed_id)
863 .copied()
864 .unwrap_or(false);
865 let mut offset = if pin_active {
866 pin_resolved
867 } else if request_wrote {
868 stored
869 } else {
870 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
871 .unwrap_or(stored)
872 }
873 .clamp(0.0, max_offset);
874
875 ui_state
876 .scroll
877 .offsets
878 .insert(node.computed_id.clone(), offset);
879
880 let (start, start_y, end) = dynamic_visible_range(&row_heights, gap, offset, inner.h);
881 let mut realized_rows = layout_dynamic_range(
882 node,
883 DynamicRangeCtx {
884 inner,
885 row_keys: &row_keys,
886 width_bucket,
887 build_row: &fns.build_row,
888 },
889 offset,
890 start,
891 start_y,
892 end,
893 ui_state,
894 );
895
896 row_heights = dynamic_row_heights(
897 node,
898 &row_keys,
899 width_bucket,
900 estimated_row_height,
901 ui_state,
902 );
903 let total_h = virtual_total_height(count, row_heights.iter().sum(), gap);
904 let max_offset = (total_h - inner.h).max(0.0);
905 let corrected_offset = if pin_active {
906 match node.pin_policy {
907 crate::tree::PinPolicy::End => max_offset,
908 crate::tree::PinPolicy::Start => 0.0,
909 crate::tree::PinPolicy::None => unreachable!(),
910 }
911 } else if request_wrote {
912 offset
913 } else {
914 dynamic_anchor_offset(node, &row_keys, &row_heights, gap, stored, ui_state)
915 .unwrap_or(offset)
916 }
917 .clamp(0.0, max_offset);
918 if (corrected_offset - offset).abs() > 0.01 {
919 let dy = offset - corrected_offset;
920 for child in &node.children {
921 shift_subtree_y(child, dy, ui_state);
922 }
923 for row in &mut realized_rows {
924 row.rect.y += dy;
925 }
926 offset = corrected_offset;
927 ui_state
928 .scroll
929 .offsets
930 .insert(node.computed_id.clone(), offset);
931 }
932 if matches!(node.pin_policy, crate::tree::PinPolicy::End) {
933 ui_state
934 .scroll
935 .pin_prev_max
936 .insert(node.computed_id.clone(), max_offset);
937 }
938 write_virtual_scroll_metrics(node, inner, total_h, max_offset, offset, ui_state);
939
940 if let Some(anchor) = choose_dynamic_anchor(fns.anchor_policy, inner, offset, &realized_rows) {
941 ui_state
942 .scroll
943 .virtual_anchors
944 .insert(node.computed_id.clone(), anchor);
945 } else {
946 ui_state.scroll.virtual_anchors.remove(&node.computed_id);
947 }
948}
949
950struct DynamicVirtualFns {
951 anchor_policy: VirtualAnchorPolicy,
952 row_key: Arc<dyn Fn(usize) -> String + Send + Sync>,
953 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
954}
955
956#[derive(Clone, Copy)]
957struct DynamicRangeCtx<'a> {
958 inner: Rect,
959 row_keys: &'a [String],
960 width_bucket: u32,
961 build_row: &'a Arc<dyn Fn(usize) -> El + Send + Sync>,
962}
963
964fn virtual_width_bucket(width: f32) -> u32 {
965 width.max(0.0).round().min(u32::MAX as f32) as u32
966}
967
968fn prune_dynamic_measurements(node: &El, row_keys: &[String], ui_state: &mut UiState) {
969 let Some(measurements) = ui_state
970 .scroll
971 .measured_row_heights
972 .get_mut(&node.computed_id)
973 else {
974 return;
975 };
976 let live_keys = row_keys
977 .iter()
978 .map(String::as_str)
979 .collect::<FxHashSet<_>>();
980 measurements.retain(|key, widths| {
981 let live = live_keys.contains(key.as_str());
982 if live {
983 widths.retain(|_, h| h.is_finite() && *h >= 0.0);
984 }
985 live && !widths.is_empty()
986 });
987 if measurements.is_empty() {
988 ui_state
989 .scroll
990 .measured_row_heights
991 .remove(&node.computed_id);
992 }
993}
994
995fn dynamic_row_heights(
996 node: &El,
997 row_keys: &[String],
998 width_bucket: u32,
999 estimated_row_height: f32,
1000 ui_state: &UiState,
1001) -> Vec<f32> {
1002 let measurements = ui_state.scroll.measured_row_heights.get(&node.computed_id);
1003 row_keys
1004 .iter()
1005 .map(|key| {
1006 measurements
1007 .and_then(|m| m.get(key))
1008 .and_then(|by_width| by_width.get(&width_bucket))
1009 .copied()
1010 .unwrap_or(estimated_row_height)
1011 })
1012 .collect()
1013}
1014
1015fn dynamic_row_top(row_heights: &[f32], gap: f32, target: usize) -> f32 {
1016 row_heights
1017 .iter()
1018 .take(target)
1019 .fold(0.0, |y, h| y + *h + gap)
1020}
1021
1022fn dynamic_visible_range(
1023 row_heights: &[f32],
1024 gap: f32,
1025 offset: f32,
1026 viewport_h: f32,
1027) -> (usize, f32, usize) {
1028 let count = row_heights.len();
1029 let mut start = 0;
1030 let mut y = 0.0_f32;
1031 while start < count {
1032 let h = row_heights[start];
1033 if y + h > offset {
1034 break;
1035 }
1036 y += h + gap;
1037 start += 1;
1038 }
1039
1040 let mut end = start;
1041 let mut cursor = y;
1042 let viewport_bottom = offset + viewport_h;
1043 while end < count && cursor < viewport_bottom {
1044 let h = row_heights[end];
1045 end += 1;
1046 cursor += h + gap;
1047 }
1048 (start, y, end)
1049}
1050
1051fn dynamic_anchor_offset(
1052 node: &El,
1053 row_keys: &[String],
1054 row_heights: &[f32],
1055 gap: f32,
1056 stored: f32,
1057 ui_state: &UiState,
1058) -> Option<f32> {
1059 let anchor = ui_state.scroll.virtual_anchors.get(&node.computed_id)?;
1060 let idx = if anchor.row_index < row_keys.len() && row_keys[anchor.row_index] == anchor.row_key {
1061 anchor.row_index
1062 } else {
1063 row_keys.iter().position(|key| key == &anchor.row_key)?
1064 };
1065 let row_h = row_heights.get(idx).copied().unwrap_or(0.0).max(0.0);
1066 let row_point = row_h * anchor.row_fraction.clamp(0.0, 1.0);
1067 let scroll_delta = stored - anchor.resolved_offset;
1068 let viewport_y = anchor.viewport_y - scroll_delta;
1069 Some(dynamic_row_top(row_heights, gap, idx) + row_point - viewport_y)
1070}
1071
1072fn measure_dynamic_range(
1073 node: &El,
1074 ctx: DynamicRangeCtx<'_>,
1075 start: usize,
1076 end: usize,
1077 ui_state: &mut UiState,
1078) {
1079 if start >= end {
1080 return;
1081 }
1082 let mut new_measurements = Vec::new();
1083 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1084 let child = (ctx.build_row)(idx);
1085 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1086 new_measurements.push((key.clone(), actual_h));
1087 }
1088 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1089}
1090
1091fn measure_dynamic_row(node: &El, idx: usize, width: f32, child: &El) -> f32 {
1092 match child.height {
1093 Size::Fixed(v) => v.max(0.0),
1094 Size::Hug => intrinsic_constrained(child, Some(width)).1.max(0.0),
1095 Size::Aspect(r) => (width * r).max(0.0),
1096 Size::Fill(_) => panic!(
1097 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed, Size::Hug, \
1098 or Size::Aspect; Size::Fill would absorb the viewport's height and break \
1099 virtualization",
1100 node.computed_id,
1101 ),
1102 }
1103}
1104
1105fn store_dynamic_measurements(
1106 node: &El,
1107 width_bucket: u32,
1108 measurements: Vec<(String, f32)>,
1109 ui_state: &mut UiState,
1110) {
1111 if measurements.is_empty() {
1112 return;
1113 }
1114 let entry = ui_state
1115 .scroll
1116 .measured_row_heights
1117 .entry(node.computed_id.clone())
1118 .or_default();
1119 for (row_key, h) in measurements {
1120 entry.entry(row_key).or_default().insert(width_bucket, h);
1121 }
1122}
1123
1124#[derive(Clone, Debug)]
1125struct DynamicRealizedRow {
1126 index: usize,
1127 key: String,
1128 rect: Rect,
1129}
1130
1131fn layout_dynamic_range(
1132 node: &mut El,
1133 ctx: DynamicRangeCtx<'_>,
1134 offset: f32,
1135 start: usize,
1136 start_y: f32,
1137 end: usize,
1138 ui_state: &mut UiState,
1139) -> Vec<DynamicRealizedRow> {
1140 let gap = node.gap.max(0.0);
1141 let mut cursor_y = start_y;
1142 let mut realized = Vec::new();
1143 let mut realized_rows = Vec::new();
1144 let mut new_measurements = Vec::new();
1145
1146 for (idx, key) in ctx.row_keys.iter().enumerate().take(end).skip(start) {
1147 let mut child = (ctx.build_row)(idx);
1148 assign_virtual_row_id(&mut child, &node.computed_id, idx);
1149 let actual_h = measure_dynamic_row(node, idx, ctx.inner.w, &child);
1150 new_measurements.push((key.clone(), actual_h));
1151
1152 let row_y = ctx.inner.y + cursor_y - offset;
1153 let c_rect = Rect::new(ctx.inner.x, row_y, ctx.inner.w, actual_h);
1154 ui_state
1155 .layout
1156 .computed_rects
1157 .insert(child.computed_id.clone(), c_rect);
1158 layout_children(&mut child, c_rect, ui_state);
1159
1160 realized_rows.push(DynamicRealizedRow {
1161 index: idx,
1162 key: key.clone(),
1163 rect: c_rect,
1164 });
1165 realized.push(child);
1166 cursor_y += actual_h + gap;
1167 }
1168
1169 store_dynamic_measurements(node, ctx.width_bucket, new_measurements, ui_state);
1170 node.children = realized;
1171 realized_rows
1172}
1173
1174fn choose_dynamic_anchor(
1175 policy: VirtualAnchorPolicy,
1176 inner: Rect,
1177 offset: f32,
1178 rows: &[DynamicRealizedRow],
1179) -> Option<VirtualAnchor> {
1180 let visible = rows
1181 .iter()
1182 .filter(|row| row.rect.bottom() > inner.y && row.rect.y < inner.bottom())
1183 .collect::<Vec<_>>();
1184 if visible.is_empty() {
1185 return None;
1186 }
1187
1188 let chosen = match policy {
1189 VirtualAnchorPolicy::ViewportFraction { y_fraction } => {
1190 let target_y = inner.y + inner.h * y_fraction.clamp(0.0, 1.0);
1191 visible
1192 .iter()
1193 .min_by(|a, b| {
1194 let ad = distance_to_interval(target_y, a.rect.y, a.rect.bottom());
1195 let bd = distance_to_interval(target_y, b.rect.y, b.rect.bottom());
1196 ad.total_cmp(&bd)
1197 })
1198 .copied()
1199 .map(|row| {
1200 let anchor_y = target_y.clamp(row.rect.y, row.rect.bottom());
1201 (row.clone(), anchor_y)
1202 })
1203 }
1204 VirtualAnchorPolicy::FirstVisible => {
1205 let row = visible
1206 .iter()
1207 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1208 .or_else(|| visible.first())
1209 .copied()?;
1210 let anchor_y = row.rect.y.max(inner.y);
1211 Some((row.clone(), anchor_y))
1212 }
1213 VirtualAnchorPolicy::LastVisible => {
1214 let row = visible
1215 .iter()
1216 .rev()
1217 .find(|row| row.rect.y >= inner.y && row.rect.bottom() <= inner.bottom())
1218 .or_else(|| visible.last())
1219 .copied()?;
1220 let anchor_y = row.rect.bottom().min(inner.bottom());
1221 Some((row.clone(), anchor_y))
1222 }
1223 }?;
1224
1225 let (row, anchor_y) = chosen;
1226 let row_h = row.rect.h.max(0.0);
1227 let row_fraction = if row_h > 0.0 {
1228 ((anchor_y - row.rect.y) / row_h).clamp(0.0, 1.0)
1229 } else {
1230 0.0
1231 };
1232 Some(VirtualAnchor {
1233 row_key: row.key.clone(),
1234 row_index: row.index,
1235 row_fraction,
1236 viewport_y: anchor_y - inner.y,
1237 resolved_offset: offset,
1238 })
1239}
1240
1241fn distance_to_interval(y: f32, top: f32, bottom: f32) -> f32 {
1242 if y < top {
1243 top - y
1244 } else if y > bottom {
1245 y - bottom
1246 } else {
1247 0.0
1248 }
1249}
1250
1251fn virtual_total_height(count: usize, row_sum: f32, gap: f32) -> f32 {
1252 if count == 0 {
1253 0.0
1254 } else {
1255 row_sum + gap * count.saturating_sub(1) as f32
1256 }
1257}
1258
1259fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
1267 let inner = node_rect.inset(node.padding);
1268 if node.children.is_empty() {
1269 ui_state
1270 .scroll
1271 .offsets
1272 .insert(node.computed_id.clone(), 0.0);
1273 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1274 ui_state.scroll.metrics.insert(
1275 node.computed_id.clone(),
1276 crate::state::ScrollMetrics {
1277 viewport_h: inner.h,
1278 content_h: 0.0,
1279 max_offset: 0.0,
1280 },
1281 );
1282 return;
1283 }
1284 let content_bottom = node
1285 .children
1286 .iter()
1287 .map(|c| ui_state.rect(&c.computed_id).bottom())
1288 .fold(f32::NEG_INFINITY, f32::max);
1289 let content_h = (content_bottom - inner.y).max(0.0);
1290 let max_offset = (content_h - inner.h).max(0.0);
1291
1292 let request_wrote = resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
1300
1301 let stored = ui_state
1302 .scroll
1303 .offsets
1304 .get(&node.computed_id)
1305 .copied()
1306 .unwrap_or(0.0);
1307 let stored = resolve_pin(node, stored, max_offset, ui_state);
1308 let pin_active = !matches!(node.pin_policy, crate::tree::PinPolicy::None)
1309 && ui_state
1310 .scroll
1311 .pin_active
1312 .get(&node.computed_id)
1313 .copied()
1314 .unwrap_or(false);
1315 let stored = if pin_active || request_wrote {
1316 stored
1317 } else {
1318 scroll_anchor_offset(node, inner, stored, ui_state).unwrap_or(stored)
1319 };
1320 let clamped = stored.clamp(0.0, max_offset);
1321 if clamped > 0.0 {
1322 for c in &node.children {
1323 shift_subtree_y(c, -clamped, ui_state);
1324 }
1325 }
1326 ui_state
1327 .scroll
1328 .offsets
1329 .insert(node.computed_id.clone(), clamped);
1330 ui_state.scroll.metrics.insert(
1331 node.computed_id.clone(),
1332 crate::state::ScrollMetrics {
1333 viewport_h: inner.h,
1334 content_h,
1335 max_offset,
1336 },
1337 );
1338
1339 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
1340
1341 if let Some(anchor) = choose_scroll_anchor(node, inner, clamped, ui_state) {
1342 ui_state
1343 .scroll
1344 .scroll_anchors
1345 .insert(node.computed_id.clone(), anchor);
1346 } else {
1347 ui_state.scroll.scroll_anchors.remove(&node.computed_id);
1348 }
1349}
1350
1351fn scroll_anchor_offset(node: &El, inner: Rect, stored: f32, ui_state: &UiState) -> Option<f32> {
1352 let anchor = ui_state.scroll.scroll_anchors.get(&node.computed_id)?;
1353 let rect = ui_state.layout.computed_rects.get(&anchor.node_id)?;
1354 if rect.h <= 0.0 {
1355 return None;
1356 }
1357 let rect_point = rect.h * anchor.rect_fraction.clamp(0.0, 1.0);
1358 let scroll_delta = stored - anchor.resolved_offset;
1359 let viewport_y = anchor.viewport_y - scroll_delta;
1360 Some(rect.y - inner.y + rect_point - viewport_y)
1361}
1362
1363fn choose_scroll_anchor(
1364 node: &El,
1365 inner: Rect,
1366 offset: f32,
1367 ui_state: &UiState,
1368) -> Option<ScrollAnchor> {
1369 if inner.h <= 0.0 {
1370 return None;
1371 }
1372 let target_y = inner.y + inner.h * 0.25;
1373 let mut best = None;
1374 for child in &node.children {
1375 choose_scroll_anchor_in_subtree(child, inner, target_y, 1, ui_state, &mut best);
1376 }
1377 let candidate = best?;
1378 let anchor_y = target_y.clamp(candidate.rect.y, candidate.rect.bottom());
1379 let rect_fraction = if candidate.rect.h > 0.0 {
1380 ((anchor_y - candidate.rect.y) / candidate.rect.h).clamp(0.0, 1.0)
1381 } else {
1382 0.0
1383 };
1384 Some(ScrollAnchor {
1385 node_id: candidate.node_id,
1386 rect_fraction,
1387 viewport_y: anchor_y - inner.y,
1388 resolved_offset: offset,
1389 })
1390}
1391
1392#[derive(Clone, Debug)]
1393struct ScrollAnchorCandidate {
1394 node_id: String,
1395 rect: Rect,
1396 distance: f32,
1397 depth: usize,
1398}
1399
1400fn choose_scroll_anchor_in_subtree(
1401 node: &El,
1402 inner: Rect,
1403 target_y: f32,
1404 depth: usize,
1405 ui_state: &UiState,
1406 best: &mut Option<ScrollAnchorCandidate>,
1407) {
1408 let Some(rect) = ui_state
1409 .layout
1410 .computed_rects
1411 .get(&node.computed_id)
1412 .copied()
1413 else {
1414 return;
1415 };
1416 if rect.w > 0.0 && rect.h > 0.0 && rect.bottom() > inner.y && rect.y < inner.bottom() {
1417 let distance = distance_to_interval(target_y, rect.y, rect.bottom());
1418 let candidate = ScrollAnchorCandidate {
1419 node_id: node.computed_id.clone(),
1420 rect,
1421 distance,
1422 depth,
1423 };
1424 let replace = best.as_ref().is_none_or(|current| {
1425 candidate.distance < current.distance
1426 || (candidate.distance == current.distance && candidate.depth > current.depth)
1427 || (candidate.distance == current.distance
1428 && candidate.depth == current.depth
1429 && candidate.rect.h < current.rect.h)
1430 });
1431 if replace {
1432 *best = Some(candidate);
1433 }
1434 }
1435
1436 if node.scrollable {
1437 return;
1438 }
1439 for child in &node.children {
1440 choose_scroll_anchor_in_subtree(child, inner, target_y, depth + 1, ui_state, best);
1441 }
1442}
1443
1444const PIN_EPSILON: f32 = 0.5;
1449
1450fn pin_would_be_active(
1458 node: &El,
1459 stored: f32,
1460 _max_offset: f32,
1461 ui_state: &UiState,
1462) -> Option<bool> {
1463 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
1464 match node.pin_policy {
1465 crate::tree::PinPolicy::None => None,
1466 crate::tree::PinPolicy::End => {
1467 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
1468 Some(match prev_active {
1469 None => true,
1470 Some(prev) => {
1471 let prev_max = prev_max.unwrap_or(0.0);
1472 if prev && stored < prev_max - PIN_EPSILON {
1473 false
1474 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_EPSILON {
1475 true
1476 } else {
1477 prev
1478 }
1479 }
1480 })
1481 }
1482 crate::tree::PinPolicy::Start => Some(match prev_active {
1483 None => true,
1484 Some(prev) => {
1485 if prev && stored > PIN_EPSILON {
1486 false
1487 } else if !prev && stored <= PIN_EPSILON {
1488 true
1489 } else {
1490 prev
1491 }
1492 }
1493 }),
1494 }
1495}
1496
1497fn resolve_pin(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
1510 if matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1511 ui_state.scroll.pin_active.remove(&node.computed_id);
1512 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1513 return stored;
1514 }
1515 let active = pin_would_be_active(node, stored, max_offset, ui_state).unwrap_or(false);
1516 ui_state
1517 .scroll
1518 .pin_active
1519 .insert(node.computed_id.clone(), active);
1520 match node.pin_policy {
1521 crate::tree::PinPolicy::End => {
1522 ui_state
1523 .scroll
1524 .pin_prev_max
1525 .insert(node.computed_id.clone(), max_offset);
1526 if active { max_offset } else { stored }
1527 }
1528 crate::tree::PinPolicy::Start => {
1529 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
1533 if active { 0.0 } else { stored }
1534 }
1535 crate::tree::PinPolicy::None => unreachable!(),
1536 }
1537}
1538
1539fn resolve_ensure_visible_for_scroll(
1552 node: &El,
1553 inner: Rect,
1554 content_h: f32,
1555 ui_state: &mut UiState,
1556) -> bool {
1557 if ui_state.scroll.pending_requests.is_empty() {
1558 return false;
1559 }
1560 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
1561 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
1562 let mut wrote = false;
1563 for req in pending {
1564 let ScrollRequest::EnsureVisible {
1565 container_key,
1566 y,
1567 h,
1568 } = &req
1569 else {
1570 remaining.push(req);
1571 continue;
1572 };
1573 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
1574 remaining.push(req);
1579 continue;
1580 };
1581 let inside = node.computed_id == *ancestor_id
1584 || node
1585 .computed_id
1586 .strip_prefix(ancestor_id.as_str())
1587 .is_some_and(|rest| rest.starts_with('.'));
1588 if !inside {
1589 remaining.push(req);
1590 continue;
1591 }
1592 let current = ui_state
1593 .scroll
1594 .offsets
1595 .get(&node.computed_id)
1596 .copied()
1597 .unwrap_or(0.0);
1598 let target_top = *y;
1599 let target_bottom = *y + *h;
1600 let viewport_h = inner.h;
1601 let new_offset = if target_top < current {
1608 target_top
1609 } else if target_bottom > current + viewport_h {
1610 target_bottom - viewport_h
1611 } else {
1612 continue;
1617 };
1618 let max = (content_h - viewport_h).max(0.0);
1622 let new_offset = new_offset.clamp(0.0, max);
1623 ui_state
1624 .scroll
1625 .offsets
1626 .insert(node.computed_id.clone(), new_offset);
1627 wrote = true;
1628 }
1629 ui_state.scroll.pending_requests = remaining;
1630 wrote
1631}
1632
1633fn write_thumb_rect(
1641 node: &El,
1642 inner: Rect,
1643 content_h: f32,
1644 max_offset: f32,
1645 offset: f32,
1646 ui_state: &mut UiState,
1647) {
1648 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
1649 return;
1650 }
1651 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
1652 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
1653 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
1654 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1655 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1656 let track_remaining = (inner.h - thumb_h).max(0.0);
1657 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1658 let thumb_x = inner.right() - thumb_w - track_inset;
1659 let track_x = inner.right() - track_w - track_inset;
1660 ui_state.scroll.thumb_rects.insert(
1661 node.computed_id.clone(),
1662 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1663 );
1664 ui_state.scroll.thumb_tracks.insert(
1665 node.computed_id.clone(),
1666 Rect::new(track_x, inner.y, track_w, inner.h),
1667 );
1668}
1669
1670fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1671 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1672 rect.y += dy;
1673 }
1674 if let Some(thumb) = ui_state.scroll.thumb_rects.get_mut(&node.computed_id) {
1675 thumb.y += dy;
1676 }
1677 if let Some(track) = ui_state.scroll.thumb_tracks.get_mut(&node.computed_id) {
1678 track.y += dy;
1679 }
1680 for c in &node.children {
1681 shift_subtree_y(c, dy, ui_state);
1682 }
1683}
1684
1685fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1686 let inner = node_rect.inset(node.padding);
1687 let n = node.children.len();
1688 if n == 0 {
1689 return;
1690 }
1691
1692 let total_gap = node.gap * n.saturating_sub(1) as f32;
1693 let main_extent = if vertical { inner.h } else { inner.w };
1694 let cross_extent = if vertical { inner.w } else { inner.h };
1695
1696 let intrinsics: Vec<(f32, f32)> = {
1697 crate::profile_span!("layout::axis::intrinsics");
1698 if vertical {
1699 node.children
1704 .iter()
1705 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1706 .collect()
1707 } else {
1708 row_child_intrinsics(node, main_extent)
1718 }
1719 };
1720
1721 let resolve_main = |c: &El, iw: f32, ih: f32| -> MainSize {
1730 let main_intent = if vertical { c.height } else { c.width };
1731 if let Size::Aspect(r) = main_intent {
1732 let cross_intent = if vertical { c.width } else { c.height };
1733 if !matches!(cross_intent, Size::Aspect(_)) {
1734 let cross_intrinsic = if vertical { iw } else { ih };
1735 let cross_size = match cross_intent {
1736 Size::Fixed(v) => v,
1737 Size::Hug | Size::Fill(_) => match node.align {
1738 Align::Stretch => cross_extent,
1739 Align::Start | Align::Center | Align::End => cross_intrinsic,
1740 },
1741 Size::Aspect(_) => unreachable!(),
1742 };
1743 let cross_size = if vertical {
1744 clamp_w(c, cross_size)
1745 } else {
1746 clamp_h(c, cross_size)
1747 };
1748 let main = cross_size * r.max(0.0);
1749 let clamped = if vertical {
1750 clamp_h(c, main)
1751 } else {
1752 clamp_w(c, main)
1753 };
1754 return MainSize::Resolved(clamped);
1755 }
1756 }
1757 main_size_of(c, iw, ih, vertical)
1758 };
1759
1760 let mut consumed = 0.0;
1761 let mut fill_weight_total = 0.0;
1762 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1763 match resolve_main(c, *iw, *ih) {
1764 MainSize::Resolved(v) => consumed += v,
1765 MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1766 }
1767 }
1768 let remaining = (main_extent - consumed - total_gap).max(0.0);
1769
1770 let free_after_used = if fill_weight_total == 0.0 {
1774 remaining
1775 } else {
1776 0.0
1777 };
1778 let mut cursor = match node.justify {
1779 Justify::Start => 0.0,
1780 Justify::Center => free_after_used * 0.5,
1781 Justify::End => free_after_used,
1782 Justify::SpaceBetween => 0.0,
1783 };
1784 let between_extra =
1785 if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1786 remaining / (n - 1) as f32
1787 } else {
1788 0.0
1789 };
1790 let scroll_visible = scroll_visible_content_rect(node, inner, vertical, ui_state);
1791
1792 crate::profile_span!("layout::axis::place");
1793 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1794 let main_size = match resolve_main(c, iw, ih) {
1795 MainSize::Resolved(v) => v,
1796 MainSize::Fill(w) => {
1797 let raw = remaining * w.max(0.001) / fill_weight_total.max(0.001);
1798 if vertical {
1799 clamp_h(c, raw)
1800 } else {
1801 clamp_w(c, raw)
1802 }
1803 }
1804 };
1805
1806 let cross_intent = if vertical { c.width } else { c.height };
1807 let cross_intrinsic = if vertical { iw } else { ih };
1808 let cross_size = match cross_intent {
1820 Size::Fixed(v) => v,
1821 Size::Aspect(r) => main_size * r,
1822 Size::Hug | Size::Fill(_) => match node.align {
1823 Align::Stretch => cross_extent,
1824 Align::Start | Align::Center | Align::End => cross_intrinsic,
1825 },
1826 };
1827 let cross_size = if vertical {
1828 clamp_w(c, cross_size)
1829 } else {
1830 clamp_h(c, cross_size)
1831 };
1832
1833 let cross_off = match node.align {
1834 Align::Start | Align::Stretch => 0.0,
1835 Align::Center => (cross_extent - cross_size) * 0.5,
1836 Align::End => cross_extent - cross_size,
1837 };
1838
1839 let c_rect = if vertical {
1840 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1841 } else {
1842 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1843 };
1844 ui_state
1845 .layout
1846 .computed_rects
1847 .insert(c.computed_id.clone(), c_rect);
1848 if can_prune_scroll_child(c, c_rect, scroll_visible) {
1849 let nodes = zero_descendant_rects(c, c_rect, ui_state);
1850 record_pruned_subtree(nodes);
1851 } else {
1852 layout_children(c, c_rect, ui_state);
1853 }
1854
1855 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1856 }
1857}
1858
1859const SCROLL_LAYOUT_PRUNE_OVERSCAN: f32 = 256.0;
1860
1861fn scroll_visible_content_rect(
1862 node: &El,
1863 inner: Rect,
1864 vertical: bool,
1865 ui_state: &UiState,
1866) -> Option<Rect> {
1867 if !vertical || !node.scrollable || !matches!(node.pin_policy, crate::tree::PinPolicy::None) {
1868 return None;
1869 }
1870 let offset = ui_state
1871 .scroll
1872 .offsets
1873 .get(&node.computed_id)
1874 .copied()
1875 .unwrap_or(0.0)
1876 .max(0.0);
1877 Some(Rect::new(
1878 inner.x,
1879 inner.y + offset - SCROLL_LAYOUT_PRUNE_OVERSCAN,
1880 inner.w,
1881 inner.h + 2.0 * SCROLL_LAYOUT_PRUNE_OVERSCAN,
1882 ))
1883}
1884
1885fn can_prune_scroll_child(child: &El, child_rect: Rect, visible: Option<Rect>) -> bool {
1886 let Some(visible) = visible else {
1887 return false;
1888 };
1889 child_rect.intersect(visible).is_none() && subtree_is_layout_confined(child)
1890}
1891
1892fn subtree_is_layout_confined(node: &El) -> bool {
1893 if node.translate != (0.0, 0.0)
1894 || node.scale != 1.0
1895 || node.shadow > 0.0
1896 || node.paint_overflow != Sides::zero()
1897 || node.hit_overflow != Sides::zero()
1898 || node.layout_override.is_some()
1899 || node.virtual_items.is_some()
1900 {
1901 return false;
1902 }
1903 node.children.iter().all(subtree_is_layout_confined)
1904}
1905
1906fn zero_descendant_rects(node: &El, rect: Rect, ui_state: &mut UiState) -> u64 {
1907 let mut count = 0;
1908 let zero = Rect::new(rect.x, rect.y, 0.0, 0.0);
1909 for child in &node.children {
1910 ui_state
1911 .layout
1912 .computed_rects
1913 .insert(child.computed_id.clone(), zero);
1914 count += 1 + zero_descendant_rects(child, zero, ui_state);
1915 }
1916 count
1917}
1918
1919fn record_pruned_subtree(nodes: u64) {
1920 INTRINSIC_CACHE.with(|cell| {
1921 if let Some(cache) = cell.borrow_mut().as_mut() {
1922 cache.prune.subtrees += 1;
1923 cache.prune.nodes += nodes;
1924 }
1925 });
1926}
1927
1928enum MainSize {
1929 Resolved(f32),
1930 Fill(f32),
1931}
1932
1933fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1934 let s = if vertical { c.height } else { c.width };
1935 let intr = if vertical { ih } else { iw };
1936 let clamp = |v: f32| {
1937 if vertical {
1938 clamp_h(c, v)
1939 } else {
1940 clamp_w(c, v)
1941 }
1942 };
1943 match s {
1944 Size::Fixed(v) => MainSize::Resolved(clamp(v)),
1945 Size::Hug => MainSize::Resolved(clamp(intr)),
1946 Size::Fill(w) => MainSize::Fill(w),
1947 Size::Aspect(_) => MainSize::Resolved(clamp(intr)),
1954 }
1955}
1956
1957fn child_intrinsic(
1958 c: &El,
1959 vertical: bool,
1960 parent_cross_extent: f32,
1961 parent_align: Align,
1962) -> (f32, f32) {
1963 if !vertical {
1964 return intrinsic(c);
1965 }
1966 let available_width = match c.width {
1967 Size::Fixed(v) => Some(v),
1968 Size::Fill(_) => Some(parent_cross_extent),
1969 Size::Hug => match parent_align {
1970 Align::Stretch => Some(parent_cross_extent),
1971 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1972 },
1973 Size::Aspect(_) => Some(parent_cross_extent),
1979 };
1980 intrinsic_constrained(c, available_width)
1981}
1982
1983fn row_child_intrinsics(node: &El, inner_main_extent: f32) -> Vec<(f32, f32)> {
1992 let n = node.children.len();
1993 let total_gap = node.gap * n.saturating_sub(1) as f32;
1994
1995 let mut first: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1996 let mut consumed: f32 = 0.0;
1997 let mut fill_weight_total: f32 = 0.0;
1998 for c in &node.children {
1999 match c.width {
2000 Size::Fill(w) => {
2001 fill_weight_total += w.max(0.001);
2002 first.push(None);
2003 }
2004 _ => {
2005 let (iw, ih) = intrinsic(c);
2006 consumed += iw;
2007 first.push(Some((iw, ih)));
2008 }
2009 }
2010 }
2011
2012 let fill_remaining = (inner_main_extent - consumed - total_gap).max(0.0);
2013
2014 node.children
2015 .iter()
2016 .zip(first)
2017 .map(|(c, slot)| match slot {
2018 Some(rc) => rc,
2019 None => {
2020 let weight = match c.width {
2021 Size::Fill(w) => w.max(0.001),
2022 _ => 1.0,
2023 };
2024 let av = if fill_weight_total > 0.0 {
2025 fill_remaining * weight / fill_weight_total
2026 } else {
2027 fill_remaining
2028 };
2029 intrinsic_constrained(c, Some(av))
2030 }
2031 })
2032 .collect()
2033}
2034
2035fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
2036 let constrained_width = match c.width {
2043 Size::Fixed(v) => Some(v),
2044 Size::Fill(_) | Size::Hug => Some(parent.w),
2045 Size::Aspect(_) => None,
2048 };
2049 let (iw, ih) = intrinsic_constrained(c, constrained_width);
2050 let (w, h) = match (c.width, c.height) {
2055 (Size::Aspect(_), Size::Aspect(_)) => (iw.min(parent.w), ih.min(parent.h)),
2056 (Size::Aspect(r), _) => {
2057 let h = match c.height {
2058 Size::Fixed(v) => v,
2059 Size::Hug => ih.min(parent.h),
2060 Size::Fill(_) => parent.h,
2061 Size::Aspect(_) => unreachable!(),
2062 };
2063 (h * r, h)
2064 }
2065 (_, Size::Aspect(r)) => {
2066 let w = match c.width {
2067 Size::Fixed(v) => v,
2068 Size::Hug => iw.min(parent.w),
2069 Size::Fill(_) => parent.w,
2070 Size::Aspect(_) => unreachable!(),
2071 };
2072 (w, w * r)
2073 }
2074 _ => {
2075 let w = match c.width {
2076 Size::Fixed(v) => v,
2077 Size::Hug => iw.min(parent.w),
2078 Size::Fill(_) => parent.w,
2079 Size::Aspect(_) => unreachable!(),
2080 };
2081 let h = match c.height {
2082 Size::Fixed(v) => v,
2083 Size::Hug => ih.min(parent.h),
2084 Size::Fill(_) => parent.h,
2085 Size::Aspect(_) => unreachable!(),
2086 };
2087 (w, h)
2088 }
2089 };
2090 let w = clamp_w(c, w);
2091 let h = clamp_h(c, h);
2092 let x = match align {
2093 Align::Start | Align::Stretch => parent.x,
2094 Align::Center => parent.x + (parent.w - w) * 0.5,
2095 Align::End => parent.right() - w,
2096 };
2097 let y = match justify {
2098 Justify::Start | Justify::SpaceBetween => parent.y,
2099 Justify::Center => parent.y + (parent.h - h) * 0.5,
2100 Justify::End => parent.bottom() - h,
2101 };
2102 Rect::new(x, y, w, h)
2103}
2104
2105pub fn intrinsic(c: &El) -> (f32, f32) {
2107 intrinsic_constrained(c, None)
2108}
2109
2110fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
2111 let key = intrinsic_cache_key(c, available_width);
2112 if let Some(key) = &key
2113 && let Some(cached) = INTRINSIC_CACHE.with(|cell| {
2114 let mut slot = cell.borrow_mut();
2115 let cache = slot.as_mut()?;
2116 let cached = cache.measurements.get(key).copied();
2117 if cached.is_some() {
2118 cache.stats.hits += 1;
2119 }
2120 cached
2121 })
2122 {
2123 return cached;
2124 }
2125
2126 if key.is_some() {
2127 INTRINSIC_CACHE.with(|cell| {
2128 if let Some(cache) = cell.borrow_mut().as_mut() {
2129 cache.stats.misses += 1;
2130 }
2131 });
2132 }
2133
2134 let measured = apply_aspect(
2135 c,
2136 available_width,
2137 intrinsic_constrained_uncached(c, available_width),
2138 );
2139
2140 if let Some(key) = key {
2141 INTRINSIC_CACHE.with(|cell| {
2142 if let Some(cache) = cell.borrow_mut().as_mut() {
2143 cache.measurements.insert(key, measured);
2144 }
2145 });
2146 }
2147
2148 measured
2149}
2150
2151fn apply_aspect(c: &El, available_width: Option<f32>, (iw, ih): (f32, f32)) -> (f32, f32) {
2166 match (c.width, c.height) {
2167 (Size::Aspect(_), Size::Aspect(_)) => (iw, ih),
2168 (Size::Aspect(r), _) => {
2169 (clamp_w(c, ih * r.max(0.0)), ih)
2174 }
2175 (_, Size::Aspect(r)) => {
2176 let raw_basis = match c.width {
2177 Size::Fixed(v) => v,
2178 Size::Fill(_) => available_width.unwrap_or(iw),
2179 Size::Hug | Size::Aspect(_) => iw,
2180 };
2181 let basis = clamp_w(c, raw_basis);
2185 (iw, clamp_h(c, basis * r.max(0.0)))
2186 }
2187 _ => (iw, ih),
2188 }
2189}
2190
2191fn intrinsic_cache_key(c: &El, available_width: Option<f32>) -> Option<IntrinsicCacheKey> {
2192 if INTRINSIC_CACHE.with(|cell| cell.borrow().is_none()) {
2193 return None;
2194 }
2195 if c.computed_id.is_empty() {
2196 return None;
2197 }
2198 Some(IntrinsicCacheKey {
2199 computed_id: c.computed_id.clone(),
2200 available_width_bits: available_width.map(f32::to_bits),
2201 })
2202}
2203
2204fn intrinsic_constrained_uncached(c: &El, available_width: Option<f32>) -> (f32, f32) {
2205 if c.layout_override.is_some() {
2206 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2211 panic!(
2212 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
2213 Size::Hug is not supported for custom layouts",
2214 c.computed_id,
2215 );
2216 }
2217 return apply_min(c, 0.0, 0.0);
2218 }
2219 if c.virtual_items.is_some() {
2220 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
2225 panic!(
2226 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
2227 Size::Hug would defeat virtualization",
2228 c.computed_id,
2229 );
2230 }
2231 return apply_min(c, 0.0, 0.0);
2232 }
2233 if matches!(c.kind, Kind::Inlines) {
2234 return inline_paragraph_intrinsic(c, available_width);
2235 }
2236 if matches!(c.kind, Kind::HardBreak) {
2237 return apply_min(c, 0.0, 0.0);
2241 }
2242 if matches!(c.kind, Kind::Math) {
2243 if let Some(expr) = &c.math {
2244 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
2245 return apply_min(
2246 c,
2247 layout.width + c.padding.left + c.padding.right,
2248 layout.height() + c.padding.top + c.padding.bottom,
2249 );
2250 }
2251 return apply_min(c, 0.0, 0.0);
2252 }
2253 if c.icon.is_some() {
2254 return apply_min(
2255 c,
2256 c.font_size + c.padding.left + c.padding.right,
2257 c.font_size + c.padding.top + c.padding.bottom,
2258 );
2259 }
2260 if let Some(img) = &c.image {
2261 let w = img.width() as f32 + c.padding.left + c.padding.right;
2265 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
2266 return apply_min(c, w, h);
2267 }
2268 if let Some(text) = &c.text {
2269 let content_available = match c.text_wrap {
2270 TextWrap::NoWrap => None,
2271 TextWrap::Wrap => available_width
2272 .or(match c.width {
2273 Size::Fixed(v) => Some(v),
2274 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2278 })
2279 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2280 };
2281 let display = display_text_for_measure(c, text, content_available);
2282 let layout = text_metrics::layout_text_with_line_height_and_family(
2283 &display,
2284 c.font_size,
2285 c.line_height,
2286 c.font_family,
2287 c.font_weight,
2288 c.font_mono,
2289 c.text_wrap,
2290 content_available,
2291 );
2292 let w = match (content_available, c.width) {
2293 (Some(available), Size::Hug | Size::Aspect(_)) => {
2294 let unwrapped = text_metrics::layout_text_with_family(
2295 text,
2296 c.font_size,
2297 c.font_family,
2298 c.font_weight,
2299 c.font_mono,
2300 TextWrap::NoWrap,
2301 None,
2302 );
2303 unwrapped.width.min(available) + c.padding.left + c.padding.right
2304 }
2305 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2306 available + c.padding.left + c.padding.right
2307 }
2308 (None, _) => layout.width + c.padding.left + c.padding.right,
2309 };
2310 let h = layout.height + c.padding.top + c.padding.bottom;
2311 return apply_min(c, w, h);
2312 }
2313 match c.axis {
2314 Axis::Overlay => {
2315 let mut w: f32 = 0.0;
2316 let mut h: f32 = 0.0;
2317 for ch in &c.children {
2318 let child_available =
2319 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2320 let (cw, chh) = intrinsic_constrained(ch, child_available);
2321 w = w.max(cw);
2322 h = h.max(chh);
2323 }
2324 apply_min(
2325 c,
2326 w + c.padding.left + c.padding.right,
2327 h + c.padding.top + c.padding.bottom,
2328 )
2329 }
2330 Axis::Column => {
2331 let mut w: f32 = 0.0;
2332 let mut h: f32 = c.padding.top + c.padding.bottom;
2333 let n = c.children.len();
2334 let child_available =
2335 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
2336 for (i, ch) in c.children.iter().enumerate() {
2337 let (cw, chh) = intrinsic_constrained(ch, child_available);
2338 w = w.max(cw);
2339 h += chh;
2340 if i + 1 < n {
2341 h += c.gap;
2342 }
2343 }
2344 apply_min(c, w + c.padding.left + c.padding.right, h)
2345 }
2346 Axis::Row => {
2347 let n = c.children.len();
2357 let total_gap = c.gap * n.saturating_sub(1) as f32;
2358 let inner_available = available_width
2359 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
2360
2361 let mut consumed: f32 = 0.0;
2367 let mut fill_weight_total: f32 = 0.0;
2368 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
2369 for ch in &c.children {
2370 match ch.width {
2371 Size::Fill(w) => {
2372 fill_weight_total += w.max(0.001);
2373 sizes.push(None);
2374 }
2375 _ => {
2376 let (cw, chh) = intrinsic(ch);
2377 consumed += cw;
2378 sizes.push(Some((cw, chh)));
2379 }
2380 }
2381 }
2382
2383 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
2391 let mut w_total: f32 = c.padding.left + c.padding.right;
2392 let mut h_max: f32 = 0.0;
2393 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
2394 let (cw, chh) = match slot {
2395 Some(rc) => rc,
2396 None => match (fill_remaining, fill_weight_total > 0.0) {
2397 (Some(av), true) => {
2398 let weight = match ch.width {
2399 Size::Fill(w) => w.max(0.001),
2400 _ => 1.0,
2401 };
2402 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
2403 }
2404 _ => intrinsic(ch),
2405 },
2406 };
2407 w_total += cw;
2408 if i + 1 < n {
2409 w_total += c.gap;
2410 }
2411 h_max = h_max.max(chh);
2412 }
2413 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
2414 }
2415 }
2416}
2417
2418pub(crate) fn text_layout(
2419 c: &El,
2420 available_width: Option<f32>,
2421) -> Option<text_metrics::TextLayout> {
2422 let text = c.text.as_ref()?;
2423 let content_available = match c.text_wrap {
2424 TextWrap::NoWrap => None,
2425 TextWrap::Wrap => available_width
2426 .or(match c.width {
2427 Size::Fixed(v) => Some(v),
2428 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2429 })
2430 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
2431 };
2432 let display = display_text_for_measure(c, text, content_available);
2433 Some(text_metrics::layout_text_with_line_height_and_family(
2434 &display,
2435 c.font_size,
2436 c.line_height,
2437 c.font_family,
2438 c.font_weight,
2439 c.font_mono,
2440 c.text_wrap,
2441 content_available,
2442 ))
2443}
2444
2445fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
2446 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
2447 (c.text_wrap, c.text_max_lines, available_width)
2448 {
2449 text_metrics::clamp_text_to_lines_with_family(
2450 text,
2451 c.font_size,
2452 c.font_family,
2453 c.font_weight,
2454 c.font_mono,
2455 width,
2456 max_lines,
2457 )
2458 } else {
2459 text.to_string()
2460 }
2461}
2462
2463fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
2464 if let Size::Fixed(v) = c.width {
2465 w = v;
2466 }
2467 if let Size::Fixed(v) = c.height {
2468 h = v;
2469 }
2470 (clamp_w(c, w), clamp_h(c, h))
2471}
2472
2473pub(crate) fn clamp_w(c: &El, mut w: f32) -> f32 {
2479 if let Some(max_w) = c.max_width {
2480 w = w.min(max_w);
2481 }
2482 if let Some(min_w) = c.min_width {
2483 w = w.max(min_w);
2484 }
2485 w.max(0.0)
2486}
2487
2488pub(crate) fn clamp_h(c: &El, mut h: f32) -> f32 {
2490 if let Some(max_h) = c.max_height {
2491 h = h.min(max_h);
2492 }
2493 if let Some(min_h) = c.min_height {
2494 h = h.max(min_h);
2495 }
2496 h.max(0.0)
2497}
2498
2499fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2512 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
2513 return inline_mixed_intrinsic(node, available_width);
2514 }
2515 let concat = concat_inline_text(&node.children);
2516 let size = inline_paragraph_size(node);
2517 let line_height = inline_paragraph_line_height(node);
2518 let content_available = match node.text_wrap {
2519 TextWrap::NoWrap => None,
2520 TextWrap::Wrap => available_width
2521 .or(match node.width {
2522 Size::Fixed(v) => Some(v),
2523 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2524 })
2525 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
2526 };
2527 let layout = text_metrics::layout_text_with_line_height_and_family(
2528 &concat,
2529 size,
2530 line_height,
2531 node.font_family,
2532 FontWeight::Regular,
2533 false,
2534 node.text_wrap,
2535 content_available,
2536 );
2537 let w = match (content_available, node.width) {
2538 (Some(available), Size::Hug | Size::Aspect(_)) => {
2539 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
2540 &concat,
2541 size,
2542 line_height,
2543 node.font_family,
2544 FontWeight::Regular,
2545 false,
2546 TextWrap::NoWrap,
2547 None,
2548 );
2549 unwrapped.width.min(available) + node.padding.left + node.padding.right
2550 }
2551 (Some(available), Size::Fixed(_) | Size::Fill(_)) => {
2552 available + node.padding.left + node.padding.right
2553 }
2554 (None, _) => layout.width + node.padding.left + node.padding.right,
2555 };
2556 let h = layout.height + node.padding.top + node.padding.bottom;
2557 apply_min(node, w, h)
2558}
2559
2560fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
2561 let wrap_width = match node.text_wrap {
2562 TextWrap::Wrap => available_width.or(match node.width {
2563 Size::Fixed(v) => Some(v),
2564 Size::Fill(_) | Size::Hug | Size::Aspect(_) => None,
2565 }),
2566 TextWrap::NoWrap => None,
2567 }
2568 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
2569
2570 let mut breaker = crate::text::inline_mixed::MixedInlineBreaker::new(
2571 node.text_wrap,
2572 wrap_width,
2573 node.font_size * 0.82,
2574 node.font_size * 0.22,
2575 node.line_height,
2576 );
2577
2578 for child in &node.children {
2579 match child.kind {
2580 Kind::HardBreak => {
2581 breaker.finish_line();
2582 continue;
2583 }
2584 Kind::Text => {
2585 let text = child.text.as_deref().unwrap_or("");
2586 for chunk in inline_text_chunks(text) {
2587 let is_space = chunk.chars().all(char::is_whitespace);
2588 if breaker.skips_leading_space(is_space) {
2589 continue;
2590 }
2591 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
2592 if breaker.wraps_before(is_space, w) {
2593 breaker.finish_line();
2594 }
2595 if breaker.skips_overflowing_space(is_space, w) {
2596 continue;
2597 }
2598 breaker.push(w, ascent, descent);
2599 }
2600 continue;
2601 }
2602 _ => {}
2603 }
2604 let (w, ascent, descent) = inline_child_metrics(child);
2605 if breaker.wraps_before(false, w) {
2606 breaker.finish_line();
2607 }
2608 breaker.push(w, ascent, descent);
2609 }
2610 let measurement = breaker.finish();
2611 let w = measurement.width + node.padding.left + node.padding.right;
2612 let h = measurement.height + node.padding.top + node.padding.bottom;
2613 apply_min(node, w, h)
2614}
2615
2616fn inline_text_chunks(text: &str) -> Vec<&str> {
2617 let mut chunks = Vec::new();
2618 let mut start = 0;
2619 let mut last_space = None;
2620 for (i, ch) in text.char_indices() {
2621 let is_space = ch.is_whitespace();
2622 match last_space {
2623 None => last_space = Some(is_space),
2624 Some(prev) if prev != is_space => {
2625 chunks.push(&text[start..i]);
2626 start = i;
2627 last_space = Some(is_space);
2628 }
2629 _ => {}
2630 }
2631 }
2632 if start < text.len() {
2633 chunks.push(&text[start..]);
2634 }
2635 chunks
2636}
2637
2638fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
2639 let layout = text_metrics::layout_text_with_line_height_and_family(
2640 text,
2641 child.font_size,
2642 child.line_height,
2643 child.font_family,
2644 child.font_weight,
2645 child.font_mono,
2646 TextWrap::NoWrap,
2647 None,
2648 );
2649 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
2650}
2651
2652fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
2653 match child.kind {
2654 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
2655 Kind::Math => {
2656 if let Some(expr) = &child.math {
2657 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
2658 (layout.width, layout.ascent, layout.descent)
2659 } else {
2660 (0.0, 0.0, 0.0)
2661 }
2662 }
2663 _ => (0.0, 0.0, 0.0),
2664 }
2665}
2666
2667fn concat_inline_text(children: &[El]) -> String {
2674 let mut s = String::new();
2675 for c in children {
2676 match c.kind {
2677 Kind::Text => {
2678 if let Some(t) = &c.text {
2679 s.push_str(t);
2680 }
2681 }
2682 Kind::HardBreak => s.push('\n'),
2683 _ => {}
2684 }
2685 }
2686 s
2687}
2688
2689fn inline_paragraph_size(node: &El) -> f32 {
2693 let mut size: f32 = node.font_size;
2694 for c in &node.children {
2695 if matches!(c.kind, Kind::Text) {
2696 size = size.max(c.font_size);
2697 }
2698 }
2699 size
2700}
2701
2702fn inline_paragraph_line_height(node: &El) -> f32 {
2703 let mut line_height: f32 = node.line_height;
2704 let mut max_size: f32 = node.font_size;
2705 for c in &node.children {
2706 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
2707 max_size = c.font_size;
2708 line_height = c.line_height;
2709 }
2710 }
2711 line_height
2712}
2713
2714#[cfg(test)]
2715mod tests {
2716 use super::*;
2717 use crate::state::UiState;
2718
2719 #[test]
2724 fn align_center_shrinks_fill_child_to_intrinsic() {
2725 let mut root = column([crate::row([crate::widgets::text::text("hi")
2729 .width(Size::Fixed(40.0))
2730 .height(Size::Fixed(20.0))])])
2731 .align(Align::Center)
2732 .width(Size::Fixed(200.0))
2733 .height(Size::Fixed(100.0));
2734 let mut state = UiState::new();
2735 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2736 let row_rect = state.rect(&root.children[0].computed_id);
2737 assert!(
2740 (row_rect.x - 80.0).abs() < 0.5,
2741 "expected x≈80 (centered), got {}",
2742 row_rect.x
2743 );
2744 assert!(
2745 (row_rect.w - 40.0).abs() < 0.5,
2746 "expected w≈40 (shrunk to intrinsic), got {}",
2747 row_rect.w
2748 );
2749 }
2750
2751 #[test]
2754 fn align_stretch_preserves_fill_stretch() {
2755 let mut root = column([crate::row([crate::widgets::text::text("hi")
2756 .width(Size::Fixed(40.0))
2757 .height(Size::Fixed(20.0))])])
2758 .align(Align::Stretch)
2759 .width(Size::Fixed(200.0))
2760 .height(Size::Fixed(100.0));
2761 let mut state = UiState::new();
2762 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2763 let row_rect = state.rect(&root.children[0].computed_id);
2764 assert!(
2765 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
2766 "expected stretched (x=0, w=200), got x={} w={}",
2767 row_rect.x,
2768 row_rect.w
2769 );
2770 }
2771
2772 #[test]
2775 fn justify_center_centers_hug_children() {
2776 let mut root = column([crate::widgets::text::text("hi")
2777 .width(Size::Fixed(40.0))
2778 .height(Size::Fixed(20.0))])
2779 .justify(Justify::Center)
2780 .height(Size::Fill(1.0));
2781 let mut state = UiState::new();
2782 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2783 let child_rect = state.rect(&root.children[0].computed_id);
2784 assert!(
2786 (child_rect.y - 40.0).abs() < 0.5,
2787 "expected y≈40, got {}",
2788 child_rect.y
2789 );
2790 }
2791
2792 #[test]
2793 fn justify_end_pushes_to_bottom() {
2794 let mut root = column([crate::widgets::text::text("hi")
2795 .width(Size::Fixed(40.0))
2796 .height(Size::Fixed(20.0))])
2797 .justify(Justify::End)
2798 .height(Size::Fill(1.0));
2799 let mut state = UiState::new();
2800 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
2801 let child_rect = state.rect(&root.children[0].computed_id);
2802 assert!(
2803 (child_rect.y - 80.0).abs() < 0.5,
2804 "expected y≈80, got {}",
2805 child_rect.y
2806 );
2807 }
2808
2809 #[test]
2813 fn justify_space_between_distributes_evenly() {
2814 let row_child = || {
2815 crate::widgets::text::text("x")
2816 .width(Size::Fixed(20.0))
2817 .height(Size::Fixed(20.0))
2818 };
2819 let mut root = column([row_child(), row_child(), row_child()])
2820 .justify(Justify::SpaceBetween)
2821 .height(Size::Fixed(200.0));
2822 let mut state = UiState::new();
2823 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
2824 let y0 = state.rect(&root.children[0].computed_id).y;
2827 let y1 = state.rect(&root.children[1].computed_id).y;
2828 let y2 = state.rect(&root.children[2].computed_id).y;
2829 assert!(
2830 y0.abs() < 0.5,
2831 "first child should be flush at y=0, got {y0}"
2832 );
2833 assert!(
2834 (y1 - 90.0).abs() < 0.5,
2835 "middle child should be at y≈90, got {y1}"
2836 );
2837 assert!(
2838 (y2 - 180.0).abs() < 0.5,
2839 "last child should be flush at y≈180, got {y2}"
2840 );
2841 }
2842
2843 #[test]
2847 fn fill_weight_distributes_proportionally() {
2848 let big = crate::widgets::text::text("big")
2849 .width(Size::Fixed(40.0))
2850 .height(Size::Fill(2.0));
2851 let small = crate::widgets::text::text("small")
2852 .width(Size::Fixed(40.0))
2853 .height(Size::Fill(1.0));
2854 let mut root = column([big, small]).height(Size::Fixed(300.0));
2855 let mut state = UiState::new();
2856 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
2857 let big_h = state.rect(&root.children[0].computed_id).h;
2859 let small_h = state.rect(&root.children[1].computed_id).h;
2860 assert!(
2861 (big_h - 200.0).abs() < 0.5,
2862 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
2863 );
2864 assert!(
2865 (small_h - 100.0).abs() < 0.5,
2866 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
2867 );
2868 }
2869
2870 #[test]
2874 fn padding_on_hug_includes_in_intrinsic() {
2875 let root = column([crate::widgets::text::text("x")
2876 .width(Size::Fixed(40.0))
2877 .height(Size::Fixed(40.0))])
2878 .padding(Sides::all(20.0));
2879 let (w, h) = intrinsic(&root);
2880 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
2882 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
2883 }
2884
2885 #[test]
2889 fn align_end_pins_to_cross_axis_far_edge() {
2890 let mut root = crate::row([crate::widgets::text::text("hi")
2891 .width(Size::Fixed(40.0))
2892 .height(Size::Fixed(20.0))])
2893 .align(Align::End)
2894 .width(Size::Fixed(200.0))
2895 .height(Size::Fixed(100.0));
2896 let mut state = UiState::new();
2897 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
2898 let child_rect = state.rect(&root.children[0].computed_id);
2899 assert!(
2901 (child_rect.y - 80.0).abs() < 0.5,
2902 "expected y≈80 (pinned to bottom), got {}",
2903 child_rect.y
2904 );
2905 }
2906
2907 #[test]
2908 fn overlay_can_center_hug_child() {
2909 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
2910 .width(Size::Fixed(200.0))
2911 .height(Size::Hug)])
2912 .align(Align::Center)
2913 .justify(Justify::Center);
2914 let mut state = UiState::new();
2915 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
2916 let child_rect = state.rect(&root.children[0].computed_id);
2917 assert!(
2918 (child_rect.x - 200.0).abs() < 0.5,
2919 "expected x≈200, got {}",
2920 child_rect.x
2921 );
2922 assert!(
2923 child_rect.y > 100.0 && child_rect.y < 200.0,
2924 "expected centered y, got {}",
2925 child_rect.y
2926 );
2927 }
2928
2929 #[test]
2930 fn scroll_offset_translates_children_and_clamps_to_content() {
2931 let mut root = scroll(
2935 (0..6)
2936 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2937 )
2938 .key("list")
2939 .gap(12.0)
2940 .height(Size::Fixed(200.0));
2941 let mut state = UiState::new();
2942 assign_ids(&mut root);
2943 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2944
2945 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2946
2947 let stored = state
2949 .scroll
2950 .offsets
2951 .get(&root.computed_id)
2952 .copied()
2953 .unwrap_or(0.0);
2954 assert!(
2955 (stored - 80.0).abs() < 0.01,
2956 "offset clamped unexpectedly: {stored}"
2957 );
2958 let c0 = state.rect(&root.children[0].computed_id);
2960 assert!(
2961 (c0.y - (-80.0)).abs() < 0.01,
2962 "child 0 y = {} (expected -80)",
2963 c0.y
2964 );
2965 state
2967 .scroll
2968 .offsets
2969 .insert(root.computed_id.clone(), 9999.0);
2970 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2971 let stored = state
2972 .scroll
2973 .offsets
2974 .get(&root.computed_id)
2975 .copied()
2976 .unwrap_or(0.0);
2977 assert!(
2978 (stored - 160.0).abs() < 0.01,
2979 "overshoot clamped to {stored}"
2980 );
2981 let mut tiny =
2983 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
2984 .height(Size::Fixed(200.0));
2985 let mut tiny_state = UiState::new();
2986 assign_ids(&mut tiny);
2987 tiny_state
2988 .scroll
2989 .offsets
2990 .insert(tiny.computed_id.clone(), 50.0);
2991 layout(
2992 &mut tiny,
2993 &mut tiny_state,
2994 Rect::new(0.0, 0.0, 300.0, 200.0),
2995 );
2996 assert_eq!(
2997 tiny_state
2998 .scroll
2999 .offsets
3000 .get(&tiny.computed_id)
3001 .copied()
3002 .unwrap_or(0.0),
3003 0.0
3004 );
3005 }
3006
3007 #[test]
3008 fn scroll_layout_prunes_far_offscreen_descendants() {
3009 let far = column([crate::widgets::text::text("far row body").key("far-text")])
3010 .height(Size::Fixed(40.0));
3011 let mut root = scroll([
3012 column([crate::widgets::text::text("near row body")]).height(Size::Fixed(40.0)),
3013 crate::tree::spacer().height(Size::Fixed(400.0)),
3014 far,
3015 ])
3016 .key("list")
3017 .height(Size::Fixed(80.0));
3018 let mut state = UiState::new();
3019 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 80.0));
3020 let stats = take_prune_stats();
3021
3022 assert!(
3023 stats.subtrees >= 1,
3024 "expected at least one far scroll child to be pruned, got {stats:?}"
3025 );
3026 assert!(
3027 stats.nodes >= 1,
3028 "expected pruned descendants to be zeroed, got {stats:?}"
3029 );
3030 let far_text = state
3031 .rect_of_key(&root, "far-text")
3032 .expect("far text keeps a zero rect while pruned");
3033 assert_eq!(far_text.w, 0.0);
3034 assert_eq!(far_text.h, 0.0);
3035 }
3036
3037 #[test]
3038 fn plain_scroll_preserves_visible_anchor_when_width_reflows_content() {
3039 let make_root = || {
3040 let paragraph_text = "Variable width text wraps into a different number of lines when \
3041 the viewport narrows, which used to make a plain scroll box lose \
3042 the item the user was reading.";
3043 scroll([column((0..30).map(|i| {
3044 crate::widgets::text::paragraph(format!("{i}: {paragraph_text}"))
3045 .key(format!("paragraph-{i}"))
3046 }))
3047 .gap(8.0)])
3048 .key("article")
3049 .height(Size::Fixed(180.0))
3050 };
3051
3052 let mut root = make_root();
3053 let mut state = UiState::new();
3054 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3055
3056 state.scroll.offsets.insert(root.computed_id.clone(), 520.0);
3057 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 320.0, 180.0));
3058
3059 let anchor = state
3060 .scroll
3061 .scroll_anchors
3062 .get(&root.computed_id)
3063 .cloned()
3064 .expect("plain scroll should store a visible descendant anchor");
3065 let before_rect = state.rect(&anchor.node_id);
3066 let before_anchor_y = before_rect.y + before_rect.h * anchor.rect_fraction;
3067 let before_offset = state.scroll_offset(&root.computed_id);
3068
3069 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 180.0));
3070
3071 let after_rect = state.rect(&anchor.node_id);
3072 let after_anchor_y = after_rect.y + after_rect.h * anchor.rect_fraction;
3073 let after_offset = state.scroll_offset(&root.computed_id);
3074 assert!(
3075 (after_anchor_y - before_anchor_y).abs() < 0.5,
3076 "anchor point should stay at y={before_anchor_y}, got {after_anchor_y}"
3077 );
3078 assert!(
3079 (after_offset - before_offset).abs() > 20.0,
3080 "offset should absorb height changes above the anchor"
3081 );
3082 }
3083
3084 #[test]
3085 fn scrollbar_thumb_size_and_position_track_overflow() {
3086 let mut root = scroll(
3089 (0..6)
3090 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3091 )
3092 .gap(12.0)
3093 .height(Size::Fixed(200.0));
3094 let mut state = UiState::new();
3095 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3096
3097 let metrics = state
3098 .scroll
3099 .metrics
3100 .get(&root.computed_id)
3101 .copied()
3102 .expect("scrollable should have metrics");
3103 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
3104 assert!((metrics.content_h - 360.0).abs() < 0.01);
3105 assert!((metrics.max_offset - 160.0).abs() < 0.01);
3106
3107 let thumb = state
3108 .scroll
3109 .thumb_rects
3110 .get(&root.computed_id)
3111 .copied()
3112 .expect("scrollable with scrollbar() and overflow gets a thumb");
3113 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
3115 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
3116 assert!(thumb.y.abs() < 0.01);
3118 assert!(
3120 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
3121 "thumb anchored at {} (expected {})",
3122 thumb.x,
3123 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
3124 );
3125
3126 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
3128 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3129 let thumb = state
3130 .scroll
3131 .thumb_rects
3132 .get(&root.computed_id)
3133 .copied()
3134 .unwrap();
3135 let track_remaining = 200.0 - thumb.h;
3136 let expected_y = track_remaining * (80.0 / 160.0);
3137 assert!(
3138 (thumb.y - expected_y).abs() < 0.5,
3139 "thumb at half-scroll y = {} (expected {expected_y})",
3140 thumb.y,
3141 );
3142 }
3143
3144 #[test]
3145 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
3146 let mut root = scroll(
3150 (0..6)
3151 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3152 )
3153 .gap(12.0)
3154 .height(Size::Fixed(200.0));
3155 let mut state = UiState::new();
3156 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3157
3158 let thumb = state
3159 .scroll
3160 .thumb_rects
3161 .get(&root.computed_id)
3162 .copied()
3163 .unwrap();
3164 let track = state
3165 .scroll
3166 .thumb_tracks
3167 .get(&root.computed_id)
3168 .copied()
3169 .unwrap();
3170 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
3172 assert!(
3173 (track.right() - thumb.right()).abs() < 0.01,
3174 "track and thumb must share the right edge",
3175 );
3176 assert!(
3179 (track.h - 200.0).abs() < 0.01,
3180 "track height = {} (expected 200)",
3181 track.h,
3182 );
3183 }
3184
3185 #[test]
3186 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
3187 let mut suppressed = scroll(
3189 (0..6)
3190 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
3191 )
3192 .no_scrollbar()
3193 .height(Size::Fixed(200.0));
3194 let mut state = UiState::new();
3195 layout(
3196 &mut suppressed,
3197 &mut state,
3198 Rect::new(0.0, 0.0, 300.0, 200.0),
3199 );
3200 assert!(
3201 !state
3202 .scroll
3203 .thumb_rects
3204 .contains_key(&suppressed.computed_id)
3205 );
3206
3207 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
3209 .height(Size::Fixed(200.0));
3210 let mut tiny_state = UiState::new();
3211 layout(
3212 &mut tiny,
3213 &mut tiny_state,
3214 Rect::new(0.0, 0.0, 300.0, 200.0),
3215 );
3216 assert!(
3217 !tiny_state
3218 .scroll
3219 .thumb_rects
3220 .contains_key(&tiny.computed_id)
3221 );
3222 }
3223
3224 #[test]
3225 fn nested_scrollbar_thumb_moves_with_outer_scroll_content() {
3226 let make_root = || {
3227 scroll([
3228 crate::tree::spacer().height(Size::Fixed(80.0)),
3229 scroll((0..6).map(|i| {
3230 crate::widgets::text::text(format!("inner row {i}")).height(Size::Fixed(50.0))
3231 }))
3232 .key("inner")
3233 .height(Size::Fixed(120.0)),
3234 crate::tree::spacer().height(Size::Fixed(260.0)),
3235 ])
3236 .key("outer")
3237 .height(Size::Fixed(220.0))
3238 };
3239
3240 let mut root = make_root();
3241 let mut state = UiState::new();
3242 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3243 let inner = root
3244 .children
3245 .iter()
3246 .find(|child| child.key.as_deref() == Some("inner"))
3247 .expect("inner scroll");
3248 let inner_id = inner.computed_id.clone();
3249 let inner_rect = state.rect(&inner_id);
3250 let thumb = state
3251 .scroll
3252 .thumb_rects
3253 .get(&inner_id)
3254 .copied()
3255 .expect("inner scroll should have a thumb");
3256 let track = state
3257 .scroll
3258 .thumb_tracks
3259 .get(&inner_id)
3260 .copied()
3261 .expect("inner scroll should have a track");
3262 let thumb_rel_y = thumb.y - inner_rect.y;
3263 let track_rel_y = track.y - inner_rect.y;
3264
3265 state.scroll.offsets.insert(root.computed_id.clone(), 60.0);
3266 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 220.0));
3267 let inner_rect_after = state.rect(&inner_id);
3268 let thumb_after = state.scroll.thumb_rects.get(&inner_id).copied().unwrap();
3269 let track_after = state.scroll.thumb_tracks.get(&inner_id).copied().unwrap();
3270
3271 assert!(
3272 (inner_rect_after.y - (inner_rect.y - 60.0)).abs() < 0.5,
3273 "outer scroll should shift the inner viewport"
3274 );
3275 assert!(
3276 (thumb_after.y - inner_rect_after.y - thumb_rel_y).abs() < 0.5,
3277 "inner thumb should stay fixed relative to its viewport"
3278 );
3279 assert!(
3280 (track_after.y - inner_rect_after.y - track_rel_y).abs() < 0.5,
3281 "inner track should stay fixed relative to its viewport"
3282 );
3283 }
3284
3285 #[test]
3286 fn layout_override_places_children_at_returned_rects() {
3287 let mut root = column((0..3).map(|i| {
3289 crate::widgets::text::text(format!("dot {i}"))
3290 .width(Size::Fixed(20.0))
3291 .height(Size::Fixed(20.0))
3292 }))
3293 .width(Size::Fixed(200.0))
3294 .height(Size::Fixed(200.0))
3295 .layout(|ctx| {
3296 ctx.children
3297 .iter()
3298 .enumerate()
3299 .map(|(i, _)| {
3300 let off = i as f32 * 30.0;
3301 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
3302 })
3303 .collect()
3304 });
3305 let mut state = UiState::new();
3306 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3307 let r0 = state.rect(&root.children[0].computed_id);
3308 let r1 = state.rect(&root.children[1].computed_id);
3309 let r2 = state.rect(&root.children[2].computed_id);
3310 assert_eq!((r0.x, r0.y), (0.0, 0.0));
3311 assert_eq!((r1.x, r1.y), (30.0, 30.0));
3312 assert_eq!((r2.x, r2.y), (60.0, 60.0));
3313 }
3314
3315 #[test]
3316 fn layout_override_rect_of_key_resolves_earlier_sibling() {
3317 use crate::tree::stack;
3323 let trigger_x = 40.0;
3324 let trigger_y = 20.0;
3325 let trigger_w = 60.0;
3326 let trigger_h = 30.0;
3327 let mut root = stack([
3328 crate::widgets::button::button("Open")
3330 .key("trig")
3331 .width(Size::Fixed(trigger_w))
3332 .height(Size::Fixed(trigger_h)),
3333 stack([crate::widgets::text::text("popover")
3336 .width(Size::Fixed(80.0))
3337 .height(Size::Fixed(20.0))])
3338 .width(Size::Fill(1.0))
3339 .height(Size::Fill(1.0))
3340 .layout(|ctx| {
3341 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
3342 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
3343 }),
3344 ])
3345 .padding(Sides::xy(trigger_x, trigger_y));
3346 let mut state = UiState::new();
3347 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3348
3349 let popover_layer = &root.children[1];
3350 let panel_id = &popover_layer.children[0].computed_id;
3351 let panel_rect = state.rect(panel_id);
3352 assert!(
3355 (panel_rect.x - trigger_x).abs() < 0.01,
3356 "popover x = {} (expected {trigger_x})",
3357 panel_rect.x,
3358 );
3359 assert!(
3360 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
3361 "popover y = {} (expected {})",
3362 panel_rect.y,
3363 trigger_y + trigger_h + 4.0,
3364 );
3365 }
3366
3367 #[test]
3368 fn layout_override_rect_of_key_returns_none_for_missing_key() {
3369 let mut root = column([crate::widgets::text::text("inner")
3370 .width(Size::Fixed(40.0))
3371 .height(Size::Fixed(20.0))])
3372 .width(Size::Fixed(200.0))
3373 .height(Size::Fixed(200.0))
3374 .layout(|ctx| {
3375 assert!((ctx.rect_of_key)("nope").is_none());
3376 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3377 });
3378 let mut state = UiState::new();
3379 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3380 }
3381
3382 #[test]
3383 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
3384 use crate::tree::stack;
3390 let mut root = stack([
3391 stack([crate::widgets::text::text("panel")
3392 .width(Size::Fixed(40.0))
3393 .height(Size::Fixed(20.0))])
3394 .width(Size::Fill(1.0))
3395 .height(Size::Fill(1.0))
3396 .layout(|ctx| {
3397 assert!(
3398 (ctx.rect_of_key)("later").is_none(),
3399 "later sibling's rect must not be available yet"
3400 );
3401 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
3402 }),
3403 crate::widgets::button::button("after").key("later"),
3404 ]);
3405 let mut state = UiState::new();
3406 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3407 }
3408
3409 #[test]
3410 fn layout_override_measure_returns_intrinsic() {
3411 let mut root = column([crate::widgets::text::text("hi")
3413 .width(Size::Fixed(40.0))
3414 .height(Size::Fixed(20.0))])
3415 .width(Size::Fixed(200.0))
3416 .height(Size::Fixed(200.0))
3417 .layout(|ctx| {
3418 let (w, h) = (ctx.measure)(&ctx.children[0]);
3419 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
3420 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
3421 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
3422 });
3423 let mut state = UiState::new();
3424 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3425 let r = state.rect(&root.children[0].computed_id);
3426 assert_eq!((r.w, r.h), (40.0, 20.0));
3427 }
3428
3429 #[test]
3430 #[should_panic(expected = "returned 1 rects for 2 children")]
3431 fn layout_override_length_mismatch_panics() {
3432 let mut root = column([
3433 crate::widgets::text::text("a")
3434 .width(Size::Fixed(10.0))
3435 .height(Size::Fixed(10.0)),
3436 crate::widgets::text::text("b")
3437 .width(Size::Fixed(10.0))
3438 .height(Size::Fixed(10.0)),
3439 ])
3440 .width(Size::Fixed(200.0))
3441 .height(Size::Fixed(200.0))
3442 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
3443 let mut state = UiState::new();
3444 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3445 }
3446
3447 #[test]
3448 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
3449 fn layout_override_hug_panics() {
3450 let mut root = column([column([crate::widgets::text::text("c")])
3454 .width(Size::Hug)
3455 .height(Size::Fixed(200.0))
3456 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
3457 .width(Size::Fixed(200.0))
3458 .height(Size::Fixed(200.0));
3459 let mut state = UiState::new();
3460 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
3461 }
3462
3463 #[test]
3464 fn virtual_list_realizes_only_visible_rows() {
3465 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
3469 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3470 });
3471 let mut state = UiState::new();
3472 assign_ids(&mut root);
3473 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
3474 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3475
3476 assert_eq!(
3477 root.children.len(),
3478 5,
3479 "expected 5 realized rows, got {}",
3480 root.children.len()
3481 );
3482 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
3484 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
3485 let r0 = state.rect(&root.children[0].computed_id);
3487 assert!(
3488 (r0.y - (-20.0)).abs() < 0.5,
3489 "row 2 expected y≈-20, got {}",
3490 r0.y
3491 );
3492 }
3493
3494 #[test]
3495 fn virtual_list_gap_contributes_to_row_positions_and_content_height() {
3496 let mut root = crate::tree::virtual_list(10, 40.0, |i| {
3497 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3498 })
3499 .gap(10.0);
3500 let mut state = UiState::new();
3501 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3502
3503 assert_eq!(
3504 root.children.len(),
3505 3,
3506 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3507 );
3508 let row_1 = root
3509 .children
3510 .iter()
3511 .find(|c| c.key.as_deref() == Some("row-1"))
3512 .expect("row 1 should be realized");
3513 assert!(
3514 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3515 "gap should place row 1 at y=50"
3516 );
3517 let metrics = state
3518 .scroll
3519 .metrics
3520 .get(&root.computed_id)
3521 .expect("virtual list writes scroll metrics");
3522 assert!(
3523 (metrics.content_h - 490.0).abs() < 0.5,
3524 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3525 metrics.content_h
3526 );
3527 }
3528
3529 #[test]
3530 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
3531 let make_root = || {
3532 crate::tree::virtual_list(50, 50.0, |i| {
3533 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
3534 })
3535 };
3536
3537 let mut state = UiState::new();
3538 let mut root_a = make_root();
3539 assign_ids(&mut root_a);
3540 state
3542 .scroll
3543 .offsets
3544 .insert(root_a.computed_id.clone(), 250.0);
3545 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3546 let id_at_offset_a = root_a
3547 .children
3548 .iter()
3549 .find(|c| c.key.as_deref() == Some("row-5"))
3550 .unwrap()
3551 .computed_id
3552 .clone();
3553
3554 let mut root_b = make_root();
3556 assign_ids(&mut root_b);
3557 state
3558 .scroll
3559 .offsets
3560 .insert(root_b.computed_id.clone(), 200.0);
3561 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3562 let id_at_offset_b = root_b
3563 .children
3564 .iter()
3565 .find(|c| c.key.as_deref() == Some("row-5"))
3566 .unwrap()
3567 .computed_id
3568 .clone();
3569
3570 assert_eq!(
3571 id_at_offset_a, id_at_offset_b,
3572 "row-5's computed_id changed when scroll offset moved"
3573 );
3574 }
3575
3576 #[test]
3577 fn virtual_list_clamps_overshoot_offset() {
3578 let mut root =
3580 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3581 let mut state = UiState::new();
3582 assign_ids(&mut root);
3583 state
3584 .scroll
3585 .offsets
3586 .insert(root.computed_id.clone(), 9999.0);
3587 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3588 let stored = state
3589 .scroll
3590 .offsets
3591 .get(&root.computed_id)
3592 .copied()
3593 .unwrap_or(0.0);
3594 assert!(
3595 (stored - 300.0).abs() < 0.01,
3596 "expected clamp to 300, got {stored}"
3597 );
3598 }
3599
3600 #[test]
3601 fn virtual_list_empty_count_realizes_no_children() {
3602 let mut root =
3603 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
3604 let mut state = UiState::new();
3605 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3606 assert_eq!(root.children.len(), 0);
3607 }
3608
3609 #[test]
3610 #[should_panic(expected = "row_height > 0.0")]
3611 fn virtual_list_zero_row_height_panics() {
3612 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
3613 }
3614
3615 #[test]
3616 #[should_panic(expected = "Size::Hug would defeat virtualization")]
3617 fn virtual_list_hug_panics() {
3618 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
3619 crate::widgets::text::text(format!("r{i}"))
3620 })
3621 .height(Size::Hug)])
3622 .width(Size::Fixed(300.0))
3623 .height(Size::Fixed(200.0));
3624 let mut state = UiState::new();
3625 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3626 }
3627
3628 #[test]
3629 fn virtual_list_dyn_respects_per_row_fixed_heights() {
3630 let mut root = crate::tree::virtual_list_dyn(
3634 20,
3635 50.0,
3636 |i| format!("row-{i}"),
3637 |i| {
3638 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
3639 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3640 .key(format!("row-{i}"))
3641 .height(Size::Fixed(h))
3642 },
3643 );
3644 let mut state = UiState::new();
3645 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3646
3647 assert_eq!(
3648 root.children.len(),
3649 4,
3650 "expected 4 realized rows, got {}",
3651 root.children.len()
3652 );
3653 let ys: Vec<f32> = root
3655 .children
3656 .iter()
3657 .map(|c| state.rect(&c.computed_id).y)
3658 .collect();
3659 assert!(
3660 (ys[0] - 0.0).abs() < 0.5,
3661 "row 0 expected y≈0, got {}",
3662 ys[0]
3663 );
3664 assert!(
3665 (ys[1] - 40.0).abs() < 0.5,
3666 "row 1 expected y≈40, got {}",
3667 ys[1]
3668 );
3669 assert!(
3670 (ys[2] - 120.0).abs() < 0.5,
3671 "row 2 expected y≈120, got {}",
3672 ys[2]
3673 );
3674 assert!(
3675 (ys[3] - 160.0).abs() < 0.5,
3676 "row 3 expected y≈160, got {}",
3677 ys[3]
3678 );
3679 }
3680
3681 #[test]
3682 fn virtual_list_dyn_gap_contributes_to_row_positions_and_content_height() {
3683 let mut root = crate::tree::virtual_list_dyn(
3684 10,
3685 40.0,
3686 |i| format!("row-{i}"),
3687 |i| {
3688 crate::tree::column([crate::widgets::text::text(format!("row {i}"))])
3689 .key(format!("row-{i}"))
3690 .height(Size::Fixed(40.0))
3691 },
3692 )
3693 .gap(10.0);
3694 let mut state = UiState::new();
3695 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
3696
3697 assert_eq!(
3698 root.children.len(),
3699 3,
3700 "rows 0, 1, and 2 should intersect a 120px viewport with 40px rows and 10px gaps"
3701 );
3702 let row_1 = root
3703 .children
3704 .iter()
3705 .find(|c| c.key.as_deref() == Some("row-1"))
3706 .expect("row 1 should be realized");
3707 assert!(
3708 (state.rect(&row_1.computed_id).y - 50.0).abs() < 0.5,
3709 "gap should place row 1 at y=50"
3710 );
3711 let metrics = state
3712 .scroll
3713 .metrics
3714 .get(&root.computed_id)
3715 .expect("virtual list writes scroll metrics");
3716 assert!(
3717 (metrics.content_h - 490.0).abs() < 0.5,
3718 "10 rows x 40 plus 9 gaps x 10 should be 490, got {}",
3719 metrics.content_h
3720 );
3721 }
3722
3723 #[test]
3724 fn virtual_list_dyn_caches_measured_heights() {
3725 let mut root = crate::tree::virtual_list_dyn(
3729 50,
3730 50.0,
3731 |i| format!("row-{i}"),
3732 |i| {
3733 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3734 .key(format!("row-{i}"))
3735 .height(Size::Fixed(30.0))
3736 },
3737 );
3738 let mut state = UiState::new();
3739 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3740
3741 let measured = state
3742 .scroll
3743 .measured_row_heights
3744 .get(&root.computed_id)
3745 .expect("dynamic virtual list should populate the height cache");
3746 assert!(
3750 measured.len() >= 6,
3751 "expected ≥ 6 cached row heights, got {}",
3752 measured.len()
3753 );
3754 for by_width in measured.values() {
3755 let h = by_width
3756 .get(&300)
3757 .copied()
3758 .expect("measurement should be keyed at the 300px width bucket");
3759 assert!(
3760 (h - 30.0).abs() < 0.5,
3761 "expected cached height ≈ 30, got {h}"
3762 );
3763 }
3764 }
3765
3766 #[test]
3767 fn virtual_list_dyn_preserves_visible_anchor_when_above_measurement_changes() {
3768 let make_root = || {
3769 crate::tree::virtual_list_dyn(
3770 100,
3771 40.0,
3772 |i| format!("row-{i}"),
3773 |i| {
3774 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3775 .key(format!("row-{i}"))
3776 .height(Size::Fixed(40.0))
3777 },
3778 )
3779 };
3780 let mut root = make_root();
3781 let mut state = UiState::new();
3782 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3783
3784 state.scroll.offsets.insert(root.computed_id.clone(), 400.0);
3785 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3786
3787 let anchor = state
3788 .scroll
3789 .virtual_anchors
3790 .get(&root.computed_id)
3791 .cloned()
3792 .expect("dynamic list should store a visible anchor");
3793 let before_y = root
3794 .children
3795 .iter()
3796 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3797 .map(|child| state.rect(&child.computed_id).y)
3798 .expect("anchor row should be realized");
3799 let before_offset = state.scroll_offset(&root.computed_id);
3800
3801 state
3802 .scroll
3803 .measured_row_heights
3804 .entry(root.computed_id.clone())
3805 .or_default()
3806 .entry("row-0".to_string())
3807 .or_default()
3808 .insert(300, 120.0);
3809
3810 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3811 let after_y = root
3812 .children
3813 .iter()
3814 .find(|child| child.key.as_deref() == Some(anchor.row_key.as_str()))
3815 .map(|child| state.rect(&child.computed_id).y)
3816 .expect("anchor row should remain realized");
3817 let after_offset = state.scroll_offset(&root.computed_id);
3818
3819 assert!(
3820 (after_y - before_y).abs() < 0.5,
3821 "anchor row should stay at y={before_y}, got {after_y}"
3822 );
3823 assert!(
3824 (after_offset - (before_offset + 80.0)).abs() < 0.5,
3825 "offset should absorb the 80px measurement delta above anchor"
3826 );
3827 }
3828
3829 #[test]
3830 fn virtual_list_dyn_height_cache_is_width_bucketed() {
3831 let mut root = crate::tree::virtual_list_dyn(
3832 20,
3833 50.0,
3834 |i| format!("row-{i}"),
3835 |i| {
3836 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3837 .key(format!("row-{i}"))
3838 .height(Size::Fixed(30.0))
3839 },
3840 );
3841 let mut state = UiState::new();
3842 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3843 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 200.0));
3844
3845 let row_0 = state
3846 .scroll
3847 .measured_row_heights
3848 .get(&root.computed_id)
3849 .and_then(|m| m.get("row-0"))
3850 .expect("row 0 should be measured");
3851 assert!(
3852 row_0.contains_key(&300) && row_0.contains_key(&240),
3853 "expected width buckets 300 and 240, got {:?}",
3854 row_0.keys().collect::<Vec<_>>()
3855 );
3856 }
3857
3858 #[test]
3859 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
3860 let make_root = || {
3865 crate::tree::virtual_list_dyn(
3866 20,
3867 50.0,
3868 |i| format!("row-{i}"),
3869 |i| {
3870 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
3871 .key(format!("row-{i}"))
3872 .height(Size::Fixed(30.0))
3873 },
3874 )
3875 };
3876 let mut state = UiState::new();
3877 let mut root = make_root();
3878 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3879
3880 state
3881 .scroll
3882 .offsets
3883 .insert(root.computed_id.clone(), 9999.0);
3884 let mut root2 = make_root();
3885 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3886
3887 let measured = state
3888 .scroll
3889 .measured_row_heights
3890 .get(&root2.computed_id)
3891 .expect("dynamic virtual list should populate the height cache");
3892 let measured_sum = measured
3893 .values()
3894 .filter_map(|by_width| by_width.get(&300))
3895 .sum::<f32>();
3896 let measured_count = measured
3897 .values()
3898 .filter(|by_width| by_width.contains_key(&300))
3899 .count();
3900 let expected_total = measured_sum + (20 - measured_count) as f32 * 50.0;
3901 let expected_max_offset = expected_total - 200.0;
3902
3903 let stored = state
3904 .scroll
3905 .offsets
3906 .get(&root2.computed_id)
3907 .copied()
3908 .unwrap_or(0.0);
3909 assert!(
3910 (stored - expected_max_offset).abs() < 0.5,
3911 "expected offset clamped to {expected_max_offset}, got {stored}"
3912 );
3913 }
3914
3915 #[test]
3916 fn virtual_list_dyn_empty_count_realizes_no_children() {
3917 let mut root = crate::tree::virtual_list_dyn(
3918 0,
3919 50.0,
3920 |i| format!("row-{i}"),
3921 |i| crate::widgets::text::text(format!("r{i}")),
3922 );
3923 let mut state = UiState::new();
3924 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
3925 assert_eq!(root.children.len(), 0);
3926 }
3927
3928 #[test]
3929 #[should_panic(expected = "estimated_row_height > 0.0")]
3930 fn virtual_list_dyn_zero_estimate_panics() {
3931 let _ = crate::tree::virtual_list_dyn(
3932 10,
3933 0.0,
3934 |i| format!("row-{i}"),
3935 |i| crate::widgets::text::text(format!("r{i}")),
3936 );
3937 }
3938
3939 #[test]
3940 fn text_runs_constructor_shape_smoke() {
3941 let el = crate::tree::text_runs([
3942 crate::widgets::text::text("Hello, "),
3943 crate::widgets::text::text("world").bold(),
3944 crate::tree::hard_break(),
3945 crate::widgets::text::text("of text").italic(),
3946 ]);
3947 assert_eq!(el.kind, Kind::Inlines);
3948 assert_eq!(el.children.len(), 4);
3949 assert!(matches!(
3950 el.children[1].font_weight,
3951 FontWeight::Bold | FontWeight::Semibold
3952 ));
3953 assert_eq!(el.children[2].kind, Kind::HardBreak);
3954 assert!(el.children[3].text_italic);
3955 }
3956
3957 #[test]
3958 fn wrapped_text_hugs_multiline_height_from_available_width() {
3959 let mut root = column([crate::paragraph(
3960 "A longer sentence should wrap into multiple measured lines.",
3961 )])
3962 .width(Size::Fill(1.0))
3963 .height(Size::Hug);
3964
3965 let mut state = UiState::new();
3966 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
3967
3968 let child_rect = state.rect(&root.children[0].computed_id);
3969 assert_eq!(child_rect.w, 180.0);
3970 assert!(
3971 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
3972 "expected multiline paragraph height, got {}",
3973 child_rect.h
3974 );
3975 }
3976
3977 #[test]
3978 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
3979 const PANEL_W: f32 = 240.0;
3990 const PADDING: f32 = 18.0;
3991 const GAP: f32 = 12.0;
3992
3993 let panel = column([
3994 crate::paragraph(
3995 "A long enough warning paragraph that it has to wrap onto a second line \
3996 inside this narrow panel.",
3997 ),
3998 crate::widgets::button::button("OK").key("ok"),
3999 ])
4000 .width(Size::Fixed(PANEL_W))
4001 .height(Size::Hug)
4002 .padding(Sides::all(PADDING))
4003 .gap(GAP)
4004 .align(Align::Stretch);
4005
4006 let mut root = crate::stack([panel])
4007 .width(Size::Fill(1.0))
4008 .height(Size::Fill(1.0));
4009 let mut state = UiState::new();
4010 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4011
4012 let panel_rect = state.rect(&root.children[0].computed_id);
4013 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
4014
4015 let para_rect = state.rect(&root.children[0].children[0].computed_id);
4016 let button_rect = state.rect(&root.children[0].children[1].computed_id);
4017
4018 assert!(
4021 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
4022 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
4023 got h={}",
4024 para_rect.h
4025 );
4026
4027 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
4033 assert!(
4034 (bottom_padding - PADDING).abs() < 0.5,
4035 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
4036 );
4037 }
4038
4039 #[test]
4040 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
4041 const COL_W: f32 = 600.0;
4053 const GUTTER_W: f32 = 3.0;
4054
4055 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
4056 sed do eiusmod tempor incididunt ut labore et dolore magna \
4057 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
4058 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
4059
4060 let make_row = || {
4061 let gutter = El::new(Kind::Custom("gutter"))
4062 .width(Size::Fixed(GUTTER_W))
4063 .height(Size::Fill(1.0));
4064 let body = crate::paragraph(long).width(Size::Fill(1.0));
4065 crate::row([gutter, body]).width(Size::Fill(1.0))
4066 };
4067
4068 let mut root = column([make_row(), make_row()])
4069 .width(Size::Fixed(COL_W))
4070 .height(Size::Hug)
4071 .align(Align::Stretch);
4072 let mut state = UiState::new();
4073 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
4074
4075 let row0_rect = state.rect(&root.children[0].computed_id);
4076 let row1_rect = state.rect(&root.children[1].computed_id);
4077 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
4078
4079 let line_height = crate::tokens::TEXT_SM.line_height;
4084 assert!(
4085 para0_rect.h > line_height * 1.5,
4086 "paragraph should wrap to multiple lines at ~597px wide; \
4087 got h={} (line_height={})",
4088 para0_rect.h,
4089 line_height,
4090 );
4091 assert!(
4092 row0_rect.h > line_height * 1.5,
4093 "row 0 should accommodate the wrapped paragraph height; \
4094 got h={} (line_height={})",
4095 row0_rect.h,
4096 line_height,
4097 );
4098
4099 assert!(
4101 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
4102 "row 1 starts at y={} but row 0 occupies y={}..{}",
4103 row1_rect.y,
4104 row0_rect.y,
4105 row0_rect.y + row0_rect.h,
4106 );
4107 }
4108
4109 #[test]
4114 fn min_width_floors_resolved_cross_axis_size() {
4115 let mut root = column([crate::widgets::text::text("hi")
4116 .width(Size::Fixed(40.0))
4117 .height(Size::Fixed(20.0))
4118 .min_width(120.0)])
4119 .align(Align::Start)
4120 .width(Size::Fixed(500.0))
4121 .height(Size::Fixed(200.0));
4122 let mut state = UiState::new();
4123 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4124 let child_rect = state.rect(&root.children[0].computed_id);
4125 assert!(
4126 (child_rect.w - 120.0).abs() < 0.5,
4127 "expected child clamped up to 120 (intrinsic 40 < min 120), got w={}",
4128 child_rect.w,
4129 );
4130 }
4131
4132 #[test]
4135 fn max_width_caps_fill_child() {
4136 let mut root = crate::row([crate::widgets::text::text("body")
4137 .width(Size::Fill(1.0))
4138 .height(Size::Fixed(20.0))
4139 .max_width(160.0)])
4140 .width(Size::Fixed(800.0))
4141 .height(Size::Fixed(40.0));
4142 let mut state = UiState::new();
4143 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 40.0));
4144 let child_rect = state.rect(&root.children[0].computed_id);
4145 assert!(
4146 (child_rect.w - 160.0).abs() < 0.5,
4147 "expected Fill child capped at 160, got w={}",
4148 child_rect.w,
4149 );
4150 }
4151
4152 #[test]
4155 fn min_width_wins_over_max_width_when_conflicting() {
4156 let mut root = column([crate::widgets::text::text("x")
4157 .width(Size::Fixed(50.0))
4158 .height(Size::Fixed(20.0))
4159 .max_width(80.0)
4160 .min_width(120.0)]);
4161 let mut state = UiState::new();
4162 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 500.0, 200.0));
4163 let child_rect = state.rect(&root.children[0].computed_id);
4164 assert!(
4165 (child_rect.w - 120.0).abs() < 0.5,
4166 "expected min_width (120) to win over max_width (80), got w={}",
4167 child_rect.w,
4168 );
4169 }
4170
4171 #[test]
4175 fn min_height_floors_hug_column_inside_fixed_parent() {
4176 let inner = column([crate::widgets::text::text("a")
4177 .width(Size::Fixed(40.0))
4178 .height(Size::Fixed(20.0))])
4179 .width(Size::Fixed(80.0))
4180 .height(Size::Hug)
4181 .min_height(200.0);
4182 let mut root = column([inner])
4183 .align(Align::Start)
4184 .width(Size::Fixed(800.0))
4185 .height(Size::Fixed(600.0));
4186 let mut state = UiState::new();
4187 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
4188 let inner_rect = state.rect(&root.children[0].computed_id);
4189 assert!(
4190 (inner_rect.h - 200.0).abs() < 0.5,
4191 "expected inner column floored to min_height=200 (intrinsic ~20), got h={}",
4192 inner_rect.h,
4193 );
4194 }
4195
4196 #[test]
4205 fn row_passes_allocated_width_to_hug_column_with_wrap_text_child() {
4206 let mut root = crate::row([
4210 column([crate::widgets::text::paragraph(
4211 "A long enough description that must wrap to two lines at 148px",
4212 )])
4213 .width(Size::Fill(1.0)),
4214 crate::widgets::text::text("ok")
4215 .width(Size::Fixed(40.0))
4216 .height(Size::Fixed(20.0)),
4217 ])
4218 .gap(12.0)
4219 .align(Align::Center)
4220 .width(Size::Fixed(200.0));
4221 let mut state = UiState::new();
4222 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 600.0));
4223 let col_rect = state.rect(&root.children[0].computed_id);
4225 let para_rect = state.rect(&root.children[0].children[0].computed_id);
4226 assert!(
4227 (col_rect.h - para_rect.h).abs() < 0.5,
4228 "column height ({}) should track its wrapped child's height ({})",
4229 col_rect.h,
4230 para_rect.h,
4231 );
4232 }
4233
4234 #[test]
4238 fn aspect_on_column_main_axis_derives_from_cross() {
4239 let mut root = column([El::new(Kind::Group)
4240 .width(Size::Fill(1.0))
4241 .height(Size::Aspect(0.5))])
4242 .width(Size::Fixed(200.0))
4243 .height(Size::Fixed(400.0));
4244 let mut state = UiState::new();
4245 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 400.0));
4246 let r = state.rect(&root.children[0].computed_id);
4247 assert!(
4248 (r.w - 200.0).abs() < 0.5,
4249 "expected w≈200 (Fill), got {}",
4250 r.w,
4251 );
4252 assert!(
4253 (r.h - 100.0).abs() < 0.5,
4254 "expected h≈100 (Aspect 0.5 of 200), got {}",
4255 r.h,
4256 );
4257 }
4258
4259 #[test]
4263 fn aspect_height_pushes_siblings_in_column() {
4264 let mut root = column([
4265 El::new(Kind::Group)
4266 .width(Size::Fill(1.0))
4267 .height(Size::Aspect(0.25)),
4268 crate::widgets::text::text("caption")
4269 .width(Size::Fixed(40.0))
4270 .height(Size::Fixed(20.0)),
4271 ])
4272 .width(Size::Fixed(400.0))
4273 .height(Size::Fixed(500.0));
4274 let mut state = UiState::new();
4275 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 500.0));
4276 let img = state.rect(&root.children[0].computed_id);
4277 let cap = state.rect(&root.children[1].computed_id);
4278 assert!(
4279 (img.h - 100.0).abs() < 0.5,
4280 "expected aspect-derived height ≈100, got {}",
4281 img.h,
4282 );
4283 assert!(
4284 (cap.y - 100.0).abs() < 0.5,
4285 "caption should sit immediately below the aspect-sized El (y≈100), got y={}",
4286 cap.y,
4287 );
4288 }
4289
4290 #[test]
4294 fn aspect_on_row_cross_axis_derives_from_main() {
4295 let mut root = crate::row([El::new(Kind::Group)
4296 .height(Size::Fill(1.0))
4297 .width(Size::Aspect(2.0))])
4298 .width(Size::Fixed(800.0))
4299 .height(Size::Fixed(200.0));
4300 let mut state = UiState::new();
4301 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 200.0));
4302 let r = state.rect(&root.children[0].computed_id);
4303 assert!(
4304 (r.h - 200.0).abs() < 0.5,
4305 "expected h≈200 (Fill), got {}",
4306 r.h,
4307 );
4308 assert!(
4309 (r.w - 400.0).abs() < 0.5,
4310 "expected w≈400 (Aspect 2.0 of 200), got {}",
4311 r.w,
4312 );
4313 }
4314
4315 #[test]
4318 fn aspect_on_both_axes_falls_back_to_intrinsic() {
4319 let mut root = column([crate::widgets::text::text("hi")
4320 .width(Size::Aspect(1.0))
4321 .height(Size::Aspect(1.0))])
4322 .width(Size::Fixed(200.0))
4323 .height(Size::Fixed(200.0));
4324 let mut state = UiState::new();
4325 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
4326 let r = state.rect(&root.children[0].computed_id);
4327 assert!(
4328 r.w > 0.0 && r.h > 0.0,
4329 "expected finite size for both-Aspect fallback, got {}x{}",
4330 r.w,
4331 r.h,
4332 );
4333 }
4334
4335 #[test]
4339 fn aspect_respects_min_and_max_on_derived_axis() {
4340 let mut root = column([column([El::new(Kind::Group)
4344 .width(Size::Fill(1.0))
4345 .height(Size::Aspect(1.0))
4346 .max_height(120.0)])
4347 .width(Size::Hug)
4348 .height(Size::Hug)])
4349 .width(Size::Fixed(400.0))
4350 .height(Size::Fixed(600.0));
4351 let mut state = UiState::new();
4352 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4353 let panel = state.rect(&root.children[0].computed_id);
4354 let img = state.rect(&root.children[0].children[0].computed_id);
4355 assert!(
4356 (img.h - 120.0).abs() < 0.5,
4357 "max_height should clamp aspect-derived height to 120, got {}",
4358 img.h,
4359 );
4360 assert!(
4361 (panel.h - 120.0).abs() < 0.5,
4362 "hugging panel should match clamped child (120), got {}",
4363 panel.h,
4364 );
4365
4366 let mut root = column([column([El::new(Kind::Group)
4369 .width(Size::Fill(1.0))
4370 .height(Size::Aspect(0.1))
4371 .min_height(200.0)])
4372 .width(Size::Hug)
4373 .height(Size::Hug)])
4374 .width(Size::Fixed(400.0))
4375 .height(Size::Fixed(600.0));
4376 let mut state = UiState::new();
4377 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 600.0));
4378 let panel = state.rect(&root.children[0].computed_id);
4379 let img = state.rect(&root.children[0].children[0].computed_id);
4380 assert!(
4381 (img.h - 200.0).abs() < 0.5,
4382 "min_height should bump aspect-derived height to 200, got {}",
4383 img.h,
4384 );
4385 assert!(
4386 (panel.h - 200.0).abs() < 0.5,
4387 "hugging panel should match bumped child (200), got {}",
4388 panel.h,
4389 );
4390 }
4391
4392 #[test]
4395 fn aspect_basis_is_clamped_before_deriving() {
4396 let mut root = column([El::new(Kind::Group)
4402 .width(Size::Fill(1.0))
4403 .height(Size::Aspect(0.5))
4404 .max_width(100.0)])
4405 .width(Size::Fixed(400.0))
4406 .height(Size::Fixed(400.0));
4407 let mut state = UiState::new();
4408 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4409 let img = state.rect(&root.children[0].computed_id);
4410 assert!(
4411 (img.w - 100.0).abs() < 0.5,
4412 "max_width should cap Fill width at 100, got {}",
4413 img.w,
4414 );
4415 assert!(
4416 (img.h - 50.0).abs() < 0.5,
4417 "aspect-derived height should follow clamped width (100 * 0.5 = 50), got {}",
4418 img.h,
4419 );
4420 }
4421
4422 #[test]
4428 fn hug_column_around_fill_aspect_child_does_not_overflow() {
4429 let mut root = column([column([El::new(Kind::Group)
4436 .width(Size::Fill(1.0))
4437 .height(Size::Aspect(0.5))])
4438 .width(Size::Hug)
4439 .height(Size::Hug)])
4440 .width(Size::Fixed(400.0))
4441 .height(Size::Fixed(400.0));
4442 let mut state = UiState::new();
4443 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4444 let panel = state.rect(&root.children[0].computed_id);
4445 let img = state.rect(&root.children[0].children[0].computed_id);
4446 assert!(
4447 (panel.h - 200.0).abs() < 0.5,
4448 "hugging panel should hug to aspect-derived height 200, got {}",
4449 panel.h,
4450 );
4451 assert!(
4452 (img.h - 200.0).abs() < 0.5,
4453 "image should layout to height 200, got {}",
4454 img.h,
4455 );
4456 assert!(
4457 img.bottom() <= panel.bottom() + 0.5,
4458 "image (bottom={}) must fit within hugging panel (bottom={})",
4459 img.bottom(),
4460 panel.bottom(),
4461 );
4462 }
4463
4464 #[test]
4468 fn hugging_parent_sees_aspect_corrected_intrinsic() {
4469 let mut root = column([column([El::new(Kind::Group)
4473 .width(Size::Fixed(80.0))
4474 .height(Size::Aspect(0.5))])
4475 .width(Size::Hug)
4476 .height(Size::Hug)])
4477 .width(Size::Fixed(400.0))
4478 .height(Size::Fixed(400.0))
4479 .align(Align::Start);
4480 let mut state = UiState::new();
4481 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 400.0));
4482 let hugger = state.rect(&root.children[0].computed_id);
4483 assert!(
4484 (hugger.w - 80.0).abs() < 0.5 && (hugger.h - 40.0).abs() < 0.5,
4485 "hugging parent should be 80x40 (matching aspect-corrected intrinsic), got {}x{}",
4486 hugger.w,
4487 hugger.h,
4488 );
4489 }
4490
4491 #[test]
4493 fn max_height_caps_overlay_child_below_intrinsic() {
4494 let mut root = crate::tree::stack([column([crate::widgets::text::text("tall")
4497 .width(Size::Fixed(40.0))
4498 .height(Size::Fixed(300.0))])
4499 .width(Size::Hug)
4500 .height(Size::Hug)
4501 .max_height(100.0)])
4502 .width(Size::Fixed(600.0))
4503 .height(Size::Fixed(600.0));
4504 let mut state = UiState::new();
4505 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 600.0));
4506 let child_rect = state.rect(&root.children[0].computed_id);
4507 assert!(
4508 (child_rect.h - 100.0).abs() < 0.5,
4509 "expected child height capped at 100, got h={}",
4510 child_rect.h,
4511 );
4512 }
4513}