1use std::sync::Arc;
35
36use crate::scroll::{ScrollAlignment, ScrollRequest};
37use crate::state::UiState;
38use crate::text::metrics as text_metrics;
39use crate::tree::*;
40
41#[derive(Clone)]
71pub struct LayoutFn(pub Arc<dyn Fn(LayoutCtx) -> Vec<Rect> + Send + Sync>);
72
73impl LayoutFn {
74 pub fn new<F>(f: F) -> Self
75 where
76 F: Fn(LayoutCtx) -> Vec<Rect> + Send + Sync + 'static,
77 {
78 LayoutFn(Arc::new(f))
79 }
80}
81
82impl std::fmt::Debug for LayoutFn {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str("LayoutFn(<fn>)")
85 }
86}
87
88#[derive(Clone, Debug)]
113pub enum VirtualMode {
114 Fixed { row_height: f32 },
116 Dynamic { estimated_row_height: f32 },
120}
121
122#[derive(Clone)]
123#[non_exhaustive]
124pub struct VirtualItems {
125 pub count: usize,
126 pub mode: VirtualMode,
127 pub build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
128}
129
130impl VirtualItems {
131 pub fn new<F>(count: usize, row_height: f32, build_row: F) -> Self
132 where
133 F: Fn(usize) -> El + Send + Sync + 'static,
134 {
135 assert!(
136 row_height > 0.0,
137 "VirtualItems::new requires row_height > 0.0 (got {row_height})"
138 );
139 VirtualItems {
140 count,
141 mode: VirtualMode::Fixed { row_height },
142 build_row: Arc::new(build_row),
143 }
144 }
145
146 pub fn new_dyn<F>(count: usize, estimated_row_height: f32, build_row: F) -> Self
147 where
148 F: Fn(usize) -> El + Send + Sync + 'static,
149 {
150 assert!(
151 estimated_row_height > 0.0,
152 "VirtualItems::new_dyn requires estimated_row_height > 0.0 (got {estimated_row_height})"
153 );
154 VirtualItems {
155 count,
156 mode: VirtualMode::Dynamic {
157 estimated_row_height,
158 },
159 build_row: Arc::new(build_row),
160 }
161 }
162}
163
164impl std::fmt::Debug for VirtualItems {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("VirtualItems")
167 .field("count", &self.count)
168 .field("mode", &self.mode)
169 .field("build_row", &"<fn>")
170 .finish()
171 }
172}
173
174#[non_exhaustive]
179pub struct LayoutCtx<'a> {
180 pub container: Rect,
184 pub children: &'a [El],
187 pub measure: &'a dyn Fn(&El) -> (f32, f32),
191 pub rect_of_key: &'a dyn Fn(&str) -> Option<Rect>,
198 pub rect_of_id: &'a dyn Fn(&str) -> Option<Rect>,
204}
205
206pub fn layout(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
215 {
216 crate::profile_span!("layout::assign_ids");
217 assign_id(root, "root");
218 }
219 layout_post_assign(root, ui_state, viewport);
220}
221
222pub fn layout_post_assign(root: &mut El, ui_state: &mut UiState, viewport: Rect) {
228 {
229 crate::profile_span!("layout::root_setup");
230 ui_state
231 .layout
232 .computed_rects
233 .insert(root.computed_id.clone(), viewport);
234 rebuild_key_index(root, ui_state);
235 ui_state.scroll.metrics.clear();
239 ui_state.scroll.thumb_rects.clear();
240 ui_state.scroll.thumb_tracks.clear();
241 }
242 crate::profile_span!("layout::children");
243 layout_children(root, viewport, ui_state);
244}
245
246pub fn assign_id_appended(parent_id: &str, child: &mut El, child_index: usize) {
253 let role = role_token(&child.kind);
254 let suffix = match (&child.key, role) {
255 (Some(k), r) => format!("{r}[{k}]"),
256 (None, r) => format!("{r}.{child_index}"),
257 };
258 assign_id(child, &format!("{parent_id}.{suffix}"));
259}
260
261fn rebuild_key_index(root: &El, ui_state: &mut UiState) {
266 ui_state.layout.key_index.clear();
267 fn visit(node: &El, index: &mut rustc_hash::FxHashMap<String, String>) {
268 if let Some(key) = &node.key {
269 index
270 .entry(key.clone())
271 .or_insert_with(|| node.computed_id.clone());
272 }
273 for c in &node.children {
274 visit(c, index);
275 }
276 }
277 visit(root, &mut ui_state.layout.key_index);
278}
279
280pub fn assign_ids(root: &mut El) {
284 assign_id(root, "root");
285}
286
287fn assign_id(node: &mut El, path: &str) {
288 node.computed_id = path.to_string();
289 for (i, c) in node.children.iter_mut().enumerate() {
290 let role = role_token(&c.kind);
291 let suffix = match (&c.key, role) {
292 (Some(k), r) => format!("{r}[{k}]"),
293 (None, r) => format!("{r}.{i}"),
294 };
295 let child_path = format!("{path}.{suffix}");
296 assign_id(c, &child_path);
297 }
298}
299
300fn role_token(k: &Kind) -> &'static str {
301 match k {
302 Kind::Group => "group",
303 Kind::Card => "card",
304 Kind::Button => "button",
305 Kind::Badge => "badge",
306 Kind::Text => "text",
307 Kind::Heading => "heading",
308 Kind::Spacer => "spacer",
309 Kind::Divider => "divider",
310 Kind::Overlay => "overlay",
311 Kind::Scrim => "scrim",
312 Kind::Modal => "modal",
313 Kind::Scroll => "scroll",
314 Kind::VirtualList => "virtual_list",
315 Kind::Inlines => "inlines",
316 Kind::HardBreak => "hard_break",
317 Kind::Math => "math",
318 Kind::Image => "image",
319 Kind::Surface => "surface",
320 Kind::Vector => "vector",
321 Kind::Custom(name) => name,
322 }
323}
324
325fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
326 if matches!(node.kind, Kind::Inlines) {
327 for c in &mut node.children {
335 ui_state.layout.computed_rects.insert(
336 c.computed_id.clone(),
337 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
338 );
339 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
343 }
344 return;
345 }
346 if let Some(items) = node.virtual_items.clone() {
347 layout_virtual(node, node_rect, items, ui_state);
348 return;
349 }
350 if let Some(layout_fn) = node.layout_override.clone() {
351 layout_custom(node, node_rect, layout_fn, ui_state);
352 if node.scrollable {
353 apply_scroll_offset(node, node_rect, ui_state);
354 }
355 return;
356 }
357 match node.axis {
358 Axis::Overlay => {
359 let inner = node_rect.inset(node.padding);
360 for c in &mut node.children {
361 let c_rect = overlay_rect(c, inner, node.align, node.justify);
362 ui_state
363 .layout
364 .computed_rects
365 .insert(c.computed_id.clone(), c_rect);
366 layout_children(c, c_rect, ui_state);
367 }
368 }
369 Axis::Column => layout_axis(node, node_rect, true, ui_state),
370 Axis::Row => layout_axis(node, node_rect, false, ui_state),
371 }
372 if node.scrollable {
373 apply_scroll_offset(node, node_rect, ui_state);
374 }
375}
376
377fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
378 let inner = node_rect.inset(node.padding);
379 let measure = |c: &El| intrinsic(c);
380 let key_index = &ui_state.layout.key_index;
385 let computed_rects = &ui_state.layout.computed_rects;
386 let rect_of_key = |key: &str| -> Option<Rect> {
387 let id = key_index.get(key)?;
388 computed_rects.get(id).copied()
389 };
390 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
391 let rects = (layout_fn.0)(LayoutCtx {
392 container: inner,
393 children: &node.children,
394 measure: &measure,
395 rect_of_key: &rect_of_key,
396 rect_of_id: &rect_of_id,
397 });
398 assert_eq!(
399 rects.len(),
400 node.children.len(),
401 "LayoutFn for {:?} returned {} rects for {} children",
402 node.computed_id,
403 rects.len(),
404 node.children.len(),
405 );
406 for (c, c_rect) in node.children.iter_mut().zip(rects) {
407 ui_state
408 .layout
409 .computed_rects
410 .insert(c.computed_id.clone(), c_rect);
411 layout_children(c, c_rect, ui_state);
412 }
413}
414
415fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
421 let inner = node_rect.inset(node.padding);
422 match items.mode {
423 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
424 node,
425 inner,
426 items.count,
427 row_height,
428 items.build_row,
429 ui_state,
430 ),
431 VirtualMode::Dynamic {
432 estimated_row_height,
433 } => layout_virtual_dynamic(
434 node,
435 inner,
436 items.count,
437 estimated_row_height,
438 items.build_row,
439 ui_state,
440 ),
441 }
442}
443
444fn resolve_scroll_requests<F>(
454 node: &El,
455 inner: Rect,
456 count: usize,
457 row_extent: F,
458 ui_state: &mut UiState,
459) where
460 F: Fn(usize) -> (f32, f32),
461{
462 if ui_state.scroll.pending_requests.is_empty() {
463 return;
464 }
465 let Some(key) = node.key.as_deref() else {
466 return;
467 };
468 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
469 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
470 pending.into_iter().partition(|req| match req {
471 ScrollRequest::ToRow { list_key, .. } => list_key == key,
472 ScrollRequest::EnsureVisible { .. } => false,
475 });
476 ui_state.scroll.pending_requests = remaining;
477
478 for req in matched {
479 let ScrollRequest::ToRow { row, align, .. } = req else {
480 continue;
481 };
482 if row >= count {
483 continue;
484 }
485 let (row_top, row_h) = row_extent(row);
486 let row_bottom = row_top + row_h;
487 let viewport_h = inner.h;
488 let current = ui_state
489 .scroll
490 .offsets
491 .get(&node.computed_id)
492 .copied()
493 .unwrap_or(0.0);
494 let new_offset = match align {
495 ScrollAlignment::Start => row_top,
496 ScrollAlignment::End => row_bottom - viewport_h,
497 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
498 ScrollAlignment::Visible => {
499 if row_top < current {
500 row_top
501 } else if row_bottom > current + viewport_h {
502 row_bottom - viewport_h
503 } else {
504 continue;
505 }
506 }
507 };
508 ui_state
509 .scroll
510 .offsets
511 .insert(node.computed_id.clone(), new_offset);
512 }
513}
514
515fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
518 let max_offset = (total_h - inner.h).max(0.0);
519 let stored = ui_state
520 .scroll
521 .offsets
522 .get(&node.computed_id)
523 .copied()
524 .unwrap_or(0.0);
525 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
526 let offset = stored.clamp(0.0, max_offset);
527 ui_state
528 .scroll
529 .offsets
530 .insert(node.computed_id.clone(), offset);
531 ui_state.scroll.metrics.insert(
532 node.computed_id.clone(),
533 crate::state::ScrollMetrics {
534 viewport_h: inner.h,
535 content_h: total_h,
536 max_offset,
537 },
538 );
539 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
540 offset
541}
542
543fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
547 let role = role_token(&child.kind);
548 let suffix = match (&child.key, role) {
549 (Some(k), r) => format!("{r}[{k}]"),
550 (None, r) => format!("{r}.{global_i}"),
551 };
552 assign_id(child, &format!("{parent_id}.{suffix}"));
553}
554
555fn layout_virtual_fixed(
556 node: &mut El,
557 inner: Rect,
558 count: usize,
559 row_height: f32,
560 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
561 ui_state: &mut UiState,
562) {
563 let total_h = count as f32 * row_height;
564 resolve_scroll_requests(
565 node,
566 inner,
567 count,
568 |i| (i as f32 * row_height, row_height),
569 ui_state,
570 );
571 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
572
573 if count == 0 {
574 node.children.clear();
575 return;
576 }
577
578 let start = (offset / row_height).floor() as usize;
580 let end = (((offset + inner.h) / row_height).ceil() as usize).min(count);
581
582 let mut realized: Vec<El> = (start..end).map(|i| (build_row)(i)).collect();
583 for (vis_i, child) in realized.iter_mut().enumerate() {
584 let global_i = start + vis_i;
585 assign_virtual_row_id(child, &node.computed_id, global_i);
586
587 let row_y = inner.y + global_i as f32 * row_height - offset;
588 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
589 ui_state
590 .layout
591 .computed_rects
592 .insert(child.computed_id.clone(), c_rect);
593 layout_children(child, c_rect, ui_state);
594 }
595 node.children = realized;
596}
597
598fn layout_virtual_dynamic(
611 node: &mut El,
612 inner: Rect,
613 count: usize,
614 estimated_row_height: f32,
615 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
616 ui_state: &mut UiState,
617) {
618 if let Some(map) = ui_state
620 .scroll
621 .measured_row_heights
622 .get_mut(&node.computed_id)
623 {
624 map.retain(|i, _| *i < count);
625 if map.is_empty() {
626 ui_state
627 .scroll
628 .measured_row_heights
629 .remove(&node.computed_id);
630 }
631 }
632
633 let (measured_sum, measured_count) = ui_state
634 .scroll
635 .measured_row_heights
636 .get(&node.computed_id)
637 .map(|m| (m.values().sum::<f32>(), m.len()))
638 .unwrap_or((0.0, 0));
639 let unmeasured = count.saturating_sub(measured_count);
640 let total_h = measured_sum + (unmeasured as f32) * estimated_row_height;
641
642 let has_request = node.key.as_deref().is_some_and(|k| {
648 ui_state.scroll.pending_requests.iter().any(|r| match r {
649 ScrollRequest::ToRow { list_key, .. } => list_key == k,
650 ScrollRequest::EnsureVisible { .. } => false,
651 })
652 });
653 if has_request {
654 let measured = ui_state
657 .scroll
658 .measured_row_heights
659 .get(&node.computed_id)
660 .cloned();
661 resolve_scroll_requests(
662 node,
663 inner,
664 count,
665 |target| {
666 let row_h = |i: usize| -> f32 {
667 measured
668 .as_ref()
669 .and_then(|m| m.get(&i).copied())
670 .unwrap_or(estimated_row_height)
671 };
672 let mut top = 0.0_f32;
673 for i in 0..target {
674 top += row_h(i);
675 }
676 (top, row_h(target))
677 },
678 ui_state,
679 );
680 }
681
682 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
683
684 if count == 0 {
685 node.children.clear();
686 return;
687 }
688
689 let (start, start_y) = {
693 let measured = ui_state.scroll.measured_row_heights.get(&node.computed_id);
694 let row_h = |i: usize| -> f32 {
695 measured
696 .and_then(|m| m.get(&i).copied())
697 .unwrap_or(estimated_row_height)
698 };
699 let mut y = 0.0_f32;
700 let mut start = 0;
701 while start < count {
702 let h = row_h(start);
703 if y + h > offset {
704 break;
705 }
706 y += h;
707 start += 1;
708 }
709 (start, y)
710 };
711 let mut cursor_y = start_y;
712 let mut idx = start;
713
714 let mut realized: Vec<El> = Vec::new();
715 let mut new_measurements: Vec<(usize, f32)> = Vec::new();
716
717 while idx < count && cursor_y < offset + inner.h {
718 let mut child = (build_row)(idx);
719 assign_virtual_row_id(&mut child, &node.computed_id, idx);
720
721 let actual_h = match child.height {
724 Size::Fixed(v) => v.max(0.0),
725 Size::Hug => intrinsic_constrained(&child, Some(inner.w)).1.max(0.0),
726 Size::Fill(_) => panic!(
727 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
728 Size::Fill would absorb the viewport's height and break virtualization",
729 node.computed_id,
730 ),
731 };
732 new_measurements.push((idx, actual_h));
733
734 let row_y = inner.y + cursor_y - offset;
735 let c_rect = Rect::new(inner.x, row_y, inner.w, actual_h);
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
742 realized.push(child);
743 cursor_y += actual_h;
744 idx += 1;
745 }
746
747 if !new_measurements.is_empty() {
748 let entry = ui_state
749 .scroll
750 .measured_row_heights
751 .entry(node.computed_id.clone())
752 .or_default();
753 for (i, h) in new_measurements {
754 entry.insert(i, h);
755 }
756 }
757
758 node.children = realized;
759}
760
761fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
769 let inner = node_rect.inset(node.padding);
770 if node.children.is_empty() {
771 ui_state
772 .scroll
773 .offsets
774 .insert(node.computed_id.clone(), 0.0);
775 ui_state.scroll.metrics.insert(
776 node.computed_id.clone(),
777 crate::state::ScrollMetrics {
778 viewport_h: inner.h,
779 content_h: 0.0,
780 max_offset: 0.0,
781 },
782 );
783 return;
784 }
785 let content_bottom = node
786 .children
787 .iter()
788 .map(|c| ui_state.rect(&c.computed_id).bottom())
789 .fold(f32::NEG_INFINITY, f32::max);
790 let content_h = (content_bottom - inner.y).max(0.0);
791 let max_offset = (content_h - inner.h).max(0.0);
792
793 resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
801
802 let stored = ui_state
803 .scroll
804 .offsets
805 .get(&node.computed_id)
806 .copied()
807 .unwrap_or(0.0);
808 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
809 let clamped = stored.clamp(0.0, max_offset);
810 if clamped > 0.0 {
811 for c in &node.children {
812 shift_subtree_y(c, -clamped, ui_state);
813 }
814 }
815 ui_state
816 .scroll
817 .offsets
818 .insert(node.computed_id.clone(), clamped);
819 ui_state.scroll.metrics.insert(
820 node.computed_id.clone(),
821 crate::state::ScrollMetrics {
822 viewport_h: inner.h,
823 content_h,
824 max_offset,
825 },
826 );
827
828 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
829}
830
831const PIN_END_EPSILON: f32 = 0.5;
836
837fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
848 if !node.pin_end {
849 ui_state.scroll.pin_active.remove(&node.computed_id);
850 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
851 return stored;
852 }
853 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
854 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
855 let active = match prev_active {
856 None => true,
857 Some(prev) => {
858 let prev_max = prev_max.unwrap_or(0.0);
859 if prev && stored < prev_max - PIN_END_EPSILON {
860 false
863 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
864 true
871 } else {
872 prev
873 }
874 }
875 };
876 ui_state
877 .scroll
878 .pin_active
879 .insert(node.computed_id.clone(), active);
880 ui_state
881 .scroll
882 .pin_prev_max
883 .insert(node.computed_id.clone(), max_offset);
884 if active { max_offset } else { stored }
885}
886
887fn resolve_ensure_visible_for_scroll(
900 node: &El,
901 inner: Rect,
902 content_h: f32,
903 ui_state: &mut UiState,
904) {
905 if ui_state.scroll.pending_requests.is_empty() {
906 return;
907 }
908 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
909 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
910 for req in pending {
911 let ScrollRequest::EnsureVisible {
912 container_key,
913 y,
914 h,
915 } = &req
916 else {
917 remaining.push(req);
918 continue;
919 };
920 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
921 remaining.push(req);
926 continue;
927 };
928 let inside = node.computed_id == *ancestor_id
931 || node
932 .computed_id
933 .strip_prefix(ancestor_id.as_str())
934 .is_some_and(|rest| rest.starts_with('.'));
935 if !inside {
936 remaining.push(req);
937 continue;
938 }
939 let current = ui_state
940 .scroll
941 .offsets
942 .get(&node.computed_id)
943 .copied()
944 .unwrap_or(0.0);
945 let target_top = *y;
946 let target_bottom = *y + *h;
947 let viewport_h = inner.h;
948 let new_offset = if target_top < current {
955 target_top
956 } else if target_bottom > current + viewport_h {
957 target_bottom - viewport_h
958 } else {
959 continue;
964 };
965 let max = (content_h - viewport_h).max(0.0);
969 let new_offset = new_offset.clamp(0.0, max);
970 ui_state
971 .scroll
972 .offsets
973 .insert(node.computed_id.clone(), new_offset);
974 }
975 ui_state.scroll.pending_requests = remaining;
976}
977
978fn write_thumb_rect(
986 node: &El,
987 inner: Rect,
988 content_h: f32,
989 max_offset: f32,
990 offset: f32,
991 ui_state: &mut UiState,
992) {
993 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
994 return;
995 }
996 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
997 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
998 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
999 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
1000 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1001 let track_remaining = (inner.h - thumb_h).max(0.0);
1002 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1003 let thumb_x = inner.right() - thumb_w - track_inset;
1004 let track_x = inner.right() - track_w - track_inset;
1005 ui_state.scroll.thumb_rects.insert(
1006 node.computed_id.clone(),
1007 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1008 );
1009 ui_state.scroll.thumb_tracks.insert(
1010 node.computed_id.clone(),
1011 Rect::new(track_x, inner.y, track_w, inner.h),
1012 );
1013}
1014
1015fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1016 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1017 rect.y += dy;
1018 }
1019 for c in &node.children {
1020 shift_subtree_y(c, dy, ui_state);
1021 }
1022}
1023
1024fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1025 let inner = node_rect.inset(node.padding);
1026 let n = node.children.len();
1027 if n == 0 {
1028 return;
1029 }
1030
1031 let total_gap = node.gap * n.saturating_sub(1) as f32;
1032 let main_extent = if vertical { inner.h } else { inner.w };
1033 let cross_extent = if vertical { inner.w } else { inner.h };
1034
1035 let intrinsics: Vec<(f32, f32)> = {
1036 crate::profile_span!("layout::axis::intrinsics");
1037 node.children
1038 .iter()
1039 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1040 .collect()
1041 };
1042
1043 let mut consumed = 0.0;
1044 let mut fill_weight_total = 0.0;
1045 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1046 match main_size_of(c, *iw, *ih, vertical) {
1047 MainSize::Resolved(v) => consumed += v,
1048 MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1049 }
1050 }
1051 let remaining = (main_extent - consumed - total_gap).max(0.0);
1052
1053 let free_after_used = if fill_weight_total == 0.0 {
1057 remaining
1058 } else {
1059 0.0
1060 };
1061 let mut cursor = match node.justify {
1062 Justify::Start => 0.0,
1063 Justify::Center => free_after_used * 0.5,
1064 Justify::End => free_after_used,
1065 Justify::SpaceBetween => 0.0,
1066 };
1067 let between_extra =
1068 if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1069 remaining / (n - 1) as f32
1070 } else {
1071 0.0
1072 };
1073
1074 crate::profile_span!("layout::axis::place");
1075 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1076 let main_size = match main_size_of(c, iw, ih, vertical) {
1077 MainSize::Resolved(v) => v,
1078 MainSize::Fill(w) => remaining * w.max(0.001) / fill_weight_total.max(0.001),
1079 };
1080
1081 let cross_intent = if vertical { c.width } else { c.height };
1082 let cross_intrinsic = if vertical { iw } else { ih };
1083 let cross_size = match cross_intent {
1092 Size::Fixed(v) => v,
1093 Size::Hug | Size::Fill(_) => match node.align {
1094 Align::Stretch => cross_extent,
1095 Align::Start | Align::Center | Align::End => cross_intrinsic,
1096 },
1097 };
1098
1099 let cross_off = match node.align {
1100 Align::Start | Align::Stretch => 0.0,
1101 Align::Center => (cross_extent - cross_size) * 0.5,
1102 Align::End => cross_extent - cross_size,
1103 };
1104
1105 let c_rect = if vertical {
1106 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1107 } else {
1108 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1109 };
1110 ui_state
1111 .layout
1112 .computed_rects
1113 .insert(c.computed_id.clone(), c_rect);
1114 layout_children(c, c_rect, ui_state);
1115
1116 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1117 }
1118}
1119
1120enum MainSize {
1121 Resolved(f32),
1122 Fill(f32),
1123}
1124
1125fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1126 let s = if vertical { c.height } else { c.width };
1127 let intr = if vertical { ih } else { iw };
1128 match s {
1129 Size::Fixed(v) => MainSize::Resolved(v),
1130 Size::Hug => MainSize::Resolved(intr),
1131 Size::Fill(w) => MainSize::Fill(w),
1132 }
1133}
1134
1135fn child_intrinsic(
1136 c: &El,
1137 vertical: bool,
1138 parent_cross_extent: f32,
1139 parent_align: Align,
1140) -> (f32, f32) {
1141 if !vertical {
1142 return intrinsic(c);
1143 }
1144 let available_width = match c.width {
1145 Size::Fixed(v) => Some(v),
1146 Size::Fill(_) => Some(parent_cross_extent),
1147 Size::Hug => match parent_align {
1148 Align::Stretch => Some(parent_cross_extent),
1149 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1150 },
1151 };
1152 intrinsic_constrained(c, available_width)
1153}
1154
1155fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1156 let constrained_width = match c.width {
1163 Size::Fixed(v) => Some(v),
1164 Size::Fill(_) | Size::Hug => Some(parent.w),
1165 };
1166 let (iw, ih) = intrinsic_constrained(c, constrained_width);
1167 let w = match c.width {
1168 Size::Fixed(v) => v,
1169 Size::Hug => iw.min(parent.w),
1170 Size::Fill(_) => parent.w,
1171 };
1172 let h = match c.height {
1173 Size::Fixed(v) => v,
1174 Size::Hug => ih.min(parent.h),
1175 Size::Fill(_) => parent.h,
1176 };
1177 let x = match align {
1178 Align::Start | Align::Stretch => parent.x,
1179 Align::Center => parent.x + (parent.w - w) * 0.5,
1180 Align::End => parent.right() - w,
1181 };
1182 let y = match justify {
1183 Justify::Start | Justify::SpaceBetween => parent.y,
1184 Justify::Center => parent.y + (parent.h - h) * 0.5,
1185 Justify::End => parent.bottom() - h,
1186 };
1187 Rect::new(x, y, w, h)
1188}
1189
1190pub fn intrinsic(c: &El) -> (f32, f32) {
1192 intrinsic_constrained(c, None)
1193}
1194
1195fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1196 if c.layout_override.is_some() {
1197 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1202 panic!(
1203 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1204 Size::Hug is not supported for custom layouts",
1205 c.computed_id,
1206 );
1207 }
1208 return apply_min(c, 0.0, 0.0);
1209 }
1210 if c.virtual_items.is_some() {
1211 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1216 panic!(
1217 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1218 Size::Hug would defeat virtualization",
1219 c.computed_id,
1220 );
1221 }
1222 return apply_min(c, 0.0, 0.0);
1223 }
1224 if matches!(c.kind, Kind::Inlines) {
1225 return inline_paragraph_intrinsic(c, available_width);
1226 }
1227 if matches!(c.kind, Kind::HardBreak) {
1228 return apply_min(c, 0.0, 0.0);
1232 }
1233 if matches!(c.kind, Kind::Math) {
1234 if let Some(expr) = &c.math {
1235 let layout = crate::math::layout_math(expr, c.font_size, c.math_display);
1236 return apply_min(
1237 c,
1238 layout.width + c.padding.left + c.padding.right,
1239 layout.height() + c.padding.top + c.padding.bottom,
1240 );
1241 }
1242 return apply_min(c, 0.0, 0.0);
1243 }
1244 if c.icon.is_some() {
1245 return apply_min(
1246 c,
1247 c.font_size + c.padding.left + c.padding.right,
1248 c.font_size + c.padding.top + c.padding.bottom,
1249 );
1250 }
1251 if let Some(img) = &c.image {
1252 let w = img.width() as f32 + c.padding.left + c.padding.right;
1256 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
1257 return apply_min(c, w, h);
1258 }
1259 if let Some(text) = &c.text {
1260 let unwrapped = text_metrics::layout_text_with_family(
1261 text,
1262 c.font_size,
1263 c.font_family,
1264 c.font_weight,
1265 c.font_mono,
1266 TextWrap::NoWrap,
1267 None,
1268 );
1269 let content_available = match c.text_wrap {
1270 TextWrap::NoWrap => None,
1271 TextWrap::Wrap => available_width
1272 .or(match c.width {
1273 Size::Fixed(v) => Some(v),
1274 Size::Fill(_) | Size::Hug => None,
1275 })
1276 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1277 };
1278 let display = display_text_for_measure(c, text, content_available);
1279 let layout = text_metrics::layout_text_with_line_height_and_family(
1280 &display,
1281 c.font_size,
1282 c.line_height,
1283 c.font_family,
1284 c.font_weight,
1285 c.font_mono,
1286 c.text_wrap,
1287 content_available,
1288 );
1289 let w = content_available
1290 .map(|available| unwrapped.width.min(available) + c.padding.left + c.padding.right)
1291 .unwrap_or(layout.width + c.padding.left + c.padding.right);
1292 let h = layout.height + c.padding.top + c.padding.bottom;
1293 return apply_min(c, w, h);
1294 }
1295 match c.axis {
1296 Axis::Overlay => {
1297 let mut w: f32 = 0.0;
1298 let mut h: f32 = 0.0;
1299 for ch in &c.children {
1300 let child_available =
1301 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1302 let (cw, chh) = intrinsic_constrained(ch, child_available);
1303 w = w.max(cw);
1304 h = h.max(chh);
1305 }
1306 apply_min(
1307 c,
1308 w + c.padding.left + c.padding.right,
1309 h + c.padding.top + c.padding.bottom,
1310 )
1311 }
1312 Axis::Column => {
1313 let mut w: f32 = 0.0;
1314 let mut h: f32 = c.padding.top + c.padding.bottom;
1315 let n = c.children.len();
1316 let child_available =
1317 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1318 for (i, ch) in c.children.iter().enumerate() {
1319 let (cw, chh) = intrinsic_constrained(ch, child_available);
1320 w = w.max(cw);
1321 h += chh;
1322 if i + 1 < n {
1323 h += c.gap;
1324 }
1325 }
1326 apply_min(c, w + c.padding.left + c.padding.right, h)
1327 }
1328 Axis::Row => {
1329 let n = c.children.len();
1339 let total_gap = c.gap * n.saturating_sub(1) as f32;
1340 let inner_available = available_width
1341 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
1342
1343 let mut consumed: f32 = 0.0;
1349 let mut fill_weight_total: f32 = 0.0;
1350 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1351 for ch in &c.children {
1352 match ch.width {
1353 Size::Fill(w) => {
1354 fill_weight_total += w.max(0.001);
1355 sizes.push(None);
1356 }
1357 _ => {
1358 let (cw, chh) = intrinsic(ch);
1359 consumed += cw;
1360 sizes.push(Some((cw, chh)));
1361 }
1362 }
1363 }
1364
1365 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
1373 let mut w_total: f32 = c.padding.left + c.padding.right;
1374 let mut h_max: f32 = 0.0;
1375 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
1376 let (cw, chh) = match slot {
1377 Some(rc) => rc,
1378 None => match (fill_remaining, fill_weight_total > 0.0) {
1379 (Some(av), true) => {
1380 let weight = match ch.width {
1381 Size::Fill(w) => w.max(0.001),
1382 _ => 1.0,
1383 };
1384 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
1385 }
1386 _ => intrinsic(ch),
1387 },
1388 };
1389 w_total += cw;
1390 if i + 1 < n {
1391 w_total += c.gap;
1392 }
1393 h_max = h_max.max(chh);
1394 }
1395 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
1396 }
1397 }
1398}
1399
1400pub(crate) fn text_layout(
1401 c: &El,
1402 available_width: Option<f32>,
1403) -> Option<text_metrics::TextLayout> {
1404 let text = c.text.as_ref()?;
1405 let content_available = match c.text_wrap {
1406 TextWrap::NoWrap => None,
1407 TextWrap::Wrap => available_width
1408 .or(match c.width {
1409 Size::Fixed(v) => Some(v),
1410 Size::Fill(_) | Size::Hug => None,
1411 })
1412 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1413 };
1414 let display = display_text_for_measure(c, text, content_available);
1415 Some(text_metrics::layout_text_with_line_height_and_family(
1416 &display,
1417 c.font_size,
1418 c.line_height,
1419 c.font_family,
1420 c.font_weight,
1421 c.font_mono,
1422 c.text_wrap,
1423 content_available,
1424 ))
1425}
1426
1427fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
1428 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
1429 (c.text_wrap, c.text_max_lines, available_width)
1430 {
1431 text_metrics::clamp_text_to_lines_with_family(
1432 text,
1433 c.font_size,
1434 c.font_family,
1435 c.font_weight,
1436 c.font_mono,
1437 width,
1438 max_lines,
1439 )
1440 } else {
1441 text.to_string()
1442 }
1443}
1444
1445fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
1446 if let Size::Fixed(v) = c.width {
1447 w = v;
1448 }
1449 if let Size::Fixed(v) = c.height {
1450 h = v;
1451 }
1452 (w, h)
1453}
1454
1455fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
1468 if node.children.iter().any(|c| matches!(c.kind, Kind::Math)) {
1469 return inline_mixed_intrinsic(node, available_width);
1470 }
1471 let concat = concat_inline_text(&node.children);
1472 let size = inline_paragraph_size(node);
1473 let line_height = inline_paragraph_line_height(node);
1474 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
1475 &concat,
1476 size,
1477 line_height,
1478 node.font_family,
1479 FontWeight::Regular,
1480 false,
1481 TextWrap::NoWrap,
1482 None,
1483 );
1484 let content_available = match node.text_wrap {
1485 TextWrap::NoWrap => None,
1486 TextWrap::Wrap => available_width
1487 .or(match node.width {
1488 Size::Fixed(v) => Some(v),
1489 Size::Fill(_) | Size::Hug => None,
1490 })
1491 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
1492 };
1493 let layout = text_metrics::layout_text_with_line_height_and_family(
1494 &concat,
1495 size,
1496 line_height,
1497 node.font_family,
1498 FontWeight::Regular,
1499 false,
1500 node.text_wrap,
1501 content_available,
1502 );
1503 let w = content_available
1504 .map(|av| unwrapped.width.min(av) + node.padding.left + node.padding.right)
1505 .unwrap_or(layout.width + node.padding.left + node.padding.right);
1506 let h = layout.height + node.padding.top + node.padding.bottom;
1507 apply_min(node, w, h)
1508}
1509
1510fn inline_mixed_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
1511 let wrap_width = match node.text_wrap {
1512 TextWrap::Wrap => available_width.or(match node.width {
1513 Size::Fixed(v) => Some(v),
1514 Size::Fill(_) | Size::Hug => None,
1515 }),
1516 TextWrap::NoWrap => None,
1517 }
1518 .map(|w| (w - node.padding.left - node.padding.right).max(1.0));
1519
1520 let mut breaker = crate::inline_mixed::MixedInlineBreaker::new(
1521 node.text_wrap,
1522 wrap_width,
1523 node.font_size * 0.82,
1524 node.font_size * 0.22,
1525 node.line_height,
1526 );
1527
1528 for child in &node.children {
1529 match child.kind {
1530 Kind::HardBreak => {
1531 breaker.finish_line();
1532 continue;
1533 }
1534 Kind::Text => {
1535 let text = child.text.as_deref().unwrap_or("");
1536 for chunk in inline_text_chunks(text) {
1537 let is_space = chunk.chars().all(char::is_whitespace);
1538 if breaker.skips_leading_space(is_space) {
1539 continue;
1540 }
1541 let (w, ascent, descent) = inline_text_chunk_metrics(child, chunk);
1542 if breaker.wraps_before(is_space, w) {
1543 breaker.finish_line();
1544 }
1545 if breaker.skips_overflowing_space(is_space, w) {
1546 continue;
1547 }
1548 breaker.push(w, ascent, descent);
1549 }
1550 continue;
1551 }
1552 _ => {}
1553 }
1554 let (w, ascent, descent) = inline_child_metrics(child);
1555 if breaker.wraps_before(false, w) {
1556 breaker.finish_line();
1557 }
1558 breaker.push(w, ascent, descent);
1559 }
1560 let measurement = breaker.finish();
1561 let w = measurement.width + node.padding.left + node.padding.right;
1562 let h = measurement.height + node.padding.top + node.padding.bottom;
1563 apply_min(node, w, h)
1564}
1565
1566fn inline_text_chunks(text: &str) -> Vec<&str> {
1567 let mut chunks = Vec::new();
1568 let mut start = 0;
1569 let mut last_space = None;
1570 for (i, ch) in text.char_indices() {
1571 let is_space = ch.is_whitespace();
1572 match last_space {
1573 None => last_space = Some(is_space),
1574 Some(prev) if prev != is_space => {
1575 chunks.push(&text[start..i]);
1576 start = i;
1577 last_space = Some(is_space);
1578 }
1579 _ => {}
1580 }
1581 }
1582 if start < text.len() {
1583 chunks.push(&text[start..]);
1584 }
1585 chunks
1586}
1587
1588fn inline_text_chunk_metrics(child: &El, text: &str) -> (f32, f32, f32) {
1589 let layout = text_metrics::layout_text_with_line_height_and_family(
1590 text,
1591 child.font_size,
1592 child.line_height,
1593 child.font_family,
1594 child.font_weight,
1595 child.font_mono,
1596 TextWrap::NoWrap,
1597 None,
1598 );
1599 (layout.width, child.font_size * 0.82, child.font_size * 0.22)
1600}
1601
1602fn inline_child_metrics(child: &El) -> (f32, f32, f32) {
1603 match child.kind {
1604 Kind::Text => inline_text_chunk_metrics(child, child.text.as_deref().unwrap_or("")),
1605 Kind::Math => {
1606 if let Some(expr) = &child.math {
1607 let layout = crate::math::layout_math(expr, child.font_size, child.math_display);
1608 (layout.width, layout.ascent, layout.descent)
1609 } else {
1610 (0.0, 0.0, 0.0)
1611 }
1612 }
1613 _ => (0.0, 0.0, 0.0),
1614 }
1615}
1616
1617fn concat_inline_text(children: &[El]) -> String {
1624 let mut s = String::new();
1625 for c in children {
1626 match c.kind {
1627 Kind::Text => {
1628 if let Some(t) = &c.text {
1629 s.push_str(t);
1630 }
1631 }
1632 Kind::HardBreak => s.push('\n'),
1633 _ => {}
1634 }
1635 }
1636 s
1637}
1638
1639fn inline_paragraph_size(node: &El) -> f32 {
1643 let mut size: f32 = node.font_size;
1644 for c in &node.children {
1645 if matches!(c.kind, Kind::Text) {
1646 size = size.max(c.font_size);
1647 }
1648 }
1649 size
1650}
1651
1652fn inline_paragraph_line_height(node: &El) -> f32 {
1653 let mut line_height: f32 = node.line_height;
1654 let mut max_size: f32 = node.font_size;
1655 for c in &node.children {
1656 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
1657 max_size = c.font_size;
1658 line_height = c.line_height;
1659 }
1660 }
1661 line_height
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666 use super::*;
1667 use crate::state::UiState;
1668
1669 #[test]
1674 fn align_center_shrinks_fill_child_to_intrinsic() {
1675 let mut root = column([crate::row([crate::widgets::text::text("hi")
1679 .width(Size::Fixed(40.0))
1680 .height(Size::Fixed(20.0))])])
1681 .align(Align::Center)
1682 .width(Size::Fixed(200.0))
1683 .height(Size::Fixed(100.0));
1684 let mut state = UiState::new();
1685 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1686 let row_rect = state.rect(&root.children[0].computed_id);
1687 assert!(
1690 (row_rect.x - 80.0).abs() < 0.5,
1691 "expected x≈80 (centered), got {}",
1692 row_rect.x
1693 );
1694 assert!(
1695 (row_rect.w - 40.0).abs() < 0.5,
1696 "expected w≈40 (shrunk to intrinsic), got {}",
1697 row_rect.w
1698 );
1699 }
1700
1701 #[test]
1704 fn align_stretch_preserves_fill_stretch() {
1705 let mut root = column([crate::row([crate::widgets::text::text("hi")
1706 .width(Size::Fixed(40.0))
1707 .height(Size::Fixed(20.0))])])
1708 .align(Align::Stretch)
1709 .width(Size::Fixed(200.0))
1710 .height(Size::Fixed(100.0));
1711 let mut state = UiState::new();
1712 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1713 let row_rect = state.rect(&root.children[0].computed_id);
1714 assert!(
1715 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
1716 "expected stretched (x=0, w=200), got x={} w={}",
1717 row_rect.x,
1718 row_rect.w
1719 );
1720 }
1721
1722 #[test]
1725 fn justify_center_centers_hug_children() {
1726 let mut root = column([crate::widgets::text::text("hi")
1727 .width(Size::Fixed(40.0))
1728 .height(Size::Fixed(20.0))])
1729 .justify(Justify::Center)
1730 .height(Size::Fill(1.0));
1731 let mut state = UiState::new();
1732 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
1733 let child_rect = state.rect(&root.children[0].computed_id);
1734 assert!(
1736 (child_rect.y - 40.0).abs() < 0.5,
1737 "expected y≈40, got {}",
1738 child_rect.y
1739 );
1740 }
1741
1742 #[test]
1743 fn justify_end_pushes_to_bottom() {
1744 let mut root = column([crate::widgets::text::text("hi")
1745 .width(Size::Fixed(40.0))
1746 .height(Size::Fixed(20.0))])
1747 .justify(Justify::End)
1748 .height(Size::Fill(1.0));
1749 let mut state = UiState::new();
1750 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
1751 let child_rect = state.rect(&root.children[0].computed_id);
1752 assert!(
1753 (child_rect.y - 80.0).abs() < 0.5,
1754 "expected y≈80, got {}",
1755 child_rect.y
1756 );
1757 }
1758
1759 #[test]
1763 fn justify_space_between_distributes_evenly() {
1764 let row_child = || {
1765 crate::widgets::text::text("x")
1766 .width(Size::Fixed(20.0))
1767 .height(Size::Fixed(20.0))
1768 };
1769 let mut root = column([row_child(), row_child(), row_child()])
1770 .justify(Justify::SpaceBetween)
1771 .height(Size::Fixed(200.0));
1772 let mut state = UiState::new();
1773 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
1774 let y0 = state.rect(&root.children[0].computed_id).y;
1777 let y1 = state.rect(&root.children[1].computed_id).y;
1778 let y2 = state.rect(&root.children[2].computed_id).y;
1779 assert!(
1780 y0.abs() < 0.5,
1781 "first child should be flush at y=0, got {y0}"
1782 );
1783 assert!(
1784 (y1 - 90.0).abs() < 0.5,
1785 "middle child should be at y≈90, got {y1}"
1786 );
1787 assert!(
1788 (y2 - 180.0).abs() < 0.5,
1789 "last child should be flush at y≈180, got {y2}"
1790 );
1791 }
1792
1793 #[test]
1797 fn fill_weight_distributes_proportionally() {
1798 let big = crate::widgets::text::text("big")
1799 .width(Size::Fixed(40.0))
1800 .height(Size::Fill(2.0));
1801 let small = crate::widgets::text::text("small")
1802 .width(Size::Fixed(40.0))
1803 .height(Size::Fill(1.0));
1804 let mut root = column([big, small]).height(Size::Fixed(300.0));
1805 let mut state = UiState::new();
1806 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
1807 let big_h = state.rect(&root.children[0].computed_id).h;
1809 let small_h = state.rect(&root.children[1].computed_id).h;
1810 assert!(
1811 (big_h - 200.0).abs() < 0.5,
1812 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
1813 );
1814 assert!(
1815 (small_h - 100.0).abs() < 0.5,
1816 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
1817 );
1818 }
1819
1820 #[test]
1824 fn padding_on_hug_includes_in_intrinsic() {
1825 let root = column([crate::widgets::text::text("x")
1826 .width(Size::Fixed(40.0))
1827 .height(Size::Fixed(40.0))])
1828 .padding(Sides::all(20.0));
1829 let (w, h) = intrinsic(&root);
1830 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
1832 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
1833 }
1834
1835 #[test]
1839 fn align_end_pins_to_cross_axis_far_edge() {
1840 let mut root = crate::row([crate::widgets::text::text("hi")
1841 .width(Size::Fixed(40.0))
1842 .height(Size::Fixed(20.0))])
1843 .align(Align::End)
1844 .width(Size::Fixed(200.0))
1845 .height(Size::Fixed(100.0));
1846 let mut state = UiState::new();
1847 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1848 let child_rect = state.rect(&root.children[0].computed_id);
1849 assert!(
1851 (child_rect.y - 80.0).abs() < 0.5,
1852 "expected y≈80 (pinned to bottom), got {}",
1853 child_rect.y
1854 );
1855 }
1856
1857 #[test]
1858 fn overlay_can_center_hug_child() {
1859 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
1860 .width(Size::Fixed(200.0))
1861 .height(Size::Hug)])
1862 .align(Align::Center)
1863 .justify(Justify::Center);
1864 let mut state = UiState::new();
1865 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
1866 let child_rect = state.rect(&root.children[0].computed_id);
1867 assert!(
1868 (child_rect.x - 200.0).abs() < 0.5,
1869 "expected x≈200, got {}",
1870 child_rect.x
1871 );
1872 assert!(
1873 child_rect.y > 100.0 && child_rect.y < 200.0,
1874 "expected centered y, got {}",
1875 child_rect.y
1876 );
1877 }
1878
1879 #[test]
1880 fn scroll_offset_translates_children_and_clamps_to_content() {
1881 let mut root = scroll(
1885 (0..6)
1886 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1887 )
1888 .key("list")
1889 .gap(12.0)
1890 .height(Size::Fixed(200.0));
1891 let mut state = UiState::new();
1892 assign_ids(&mut root);
1893 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
1894
1895 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1896
1897 let stored = state
1899 .scroll
1900 .offsets
1901 .get(&root.computed_id)
1902 .copied()
1903 .unwrap_or(0.0);
1904 assert!(
1905 (stored - 80.0).abs() < 0.01,
1906 "offset clamped unexpectedly: {stored}"
1907 );
1908 let c0 = state.rect(&root.children[0].computed_id);
1910 assert!(
1911 (c0.y - (-80.0)).abs() < 0.01,
1912 "child 0 y = {} (expected -80)",
1913 c0.y
1914 );
1915 state
1917 .scroll
1918 .offsets
1919 .insert(root.computed_id.clone(), 9999.0);
1920 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1921 let stored = state
1922 .scroll
1923 .offsets
1924 .get(&root.computed_id)
1925 .copied()
1926 .unwrap_or(0.0);
1927 assert!(
1928 (stored - 160.0).abs() < 0.01,
1929 "overshoot clamped to {stored}"
1930 );
1931 let mut tiny =
1933 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
1934 .height(Size::Fixed(200.0));
1935 let mut tiny_state = UiState::new();
1936 assign_ids(&mut tiny);
1937 tiny_state
1938 .scroll
1939 .offsets
1940 .insert(tiny.computed_id.clone(), 50.0);
1941 layout(
1942 &mut tiny,
1943 &mut tiny_state,
1944 Rect::new(0.0, 0.0, 300.0, 200.0),
1945 );
1946 assert_eq!(
1947 tiny_state
1948 .scroll
1949 .offsets
1950 .get(&tiny.computed_id)
1951 .copied()
1952 .unwrap_or(0.0),
1953 0.0
1954 );
1955 }
1956
1957 #[test]
1958 fn scrollbar_thumb_size_and_position_track_overflow() {
1959 let mut root = scroll(
1962 (0..6)
1963 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1964 )
1965 .gap(12.0)
1966 .height(Size::Fixed(200.0));
1967 let mut state = UiState::new();
1968 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1969
1970 let metrics = state
1971 .scroll
1972 .metrics
1973 .get(&root.computed_id)
1974 .copied()
1975 .expect("scrollable should have metrics");
1976 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
1977 assert!((metrics.content_h - 360.0).abs() < 0.01);
1978 assert!((metrics.max_offset - 160.0).abs() < 0.01);
1979
1980 let thumb = state
1981 .scroll
1982 .thumb_rects
1983 .get(&root.computed_id)
1984 .copied()
1985 .expect("scrollable with scrollbar() and overflow gets a thumb");
1986 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
1988 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
1989 assert!(thumb.y.abs() < 0.01);
1991 assert!(
1993 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
1994 "thumb anchored at {} (expected {})",
1995 thumb.x,
1996 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
1997 );
1998
1999 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
2001 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2002 let thumb = state
2003 .scroll
2004 .thumb_rects
2005 .get(&root.computed_id)
2006 .copied()
2007 .unwrap();
2008 let track_remaining = 200.0 - thumb.h;
2009 let expected_y = track_remaining * (80.0 / 160.0);
2010 assert!(
2011 (thumb.y - expected_y).abs() < 0.5,
2012 "thumb at half-scroll y = {} (expected {expected_y})",
2013 thumb.y,
2014 );
2015 }
2016
2017 #[test]
2018 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
2019 let mut root = scroll(
2023 (0..6)
2024 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2025 )
2026 .gap(12.0)
2027 .height(Size::Fixed(200.0));
2028 let mut state = UiState::new();
2029 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2030
2031 let thumb = state
2032 .scroll
2033 .thumb_rects
2034 .get(&root.computed_id)
2035 .copied()
2036 .unwrap();
2037 let track = state
2038 .scroll
2039 .thumb_tracks
2040 .get(&root.computed_id)
2041 .copied()
2042 .unwrap();
2043 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
2045 assert!(
2046 (track.right() - thumb.right()).abs() < 0.01,
2047 "track and thumb must share the right edge",
2048 );
2049 assert!(
2052 (track.h - 200.0).abs() < 0.01,
2053 "track height = {} (expected 200)",
2054 track.h,
2055 );
2056 }
2057
2058 #[test]
2059 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
2060 let mut suppressed = scroll(
2062 (0..6)
2063 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
2064 )
2065 .no_scrollbar()
2066 .height(Size::Fixed(200.0));
2067 let mut state = UiState::new();
2068 layout(
2069 &mut suppressed,
2070 &mut state,
2071 Rect::new(0.0, 0.0, 300.0, 200.0),
2072 );
2073 assert!(
2074 !state
2075 .scroll
2076 .thumb_rects
2077 .contains_key(&suppressed.computed_id)
2078 );
2079
2080 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
2082 .height(Size::Fixed(200.0));
2083 let mut tiny_state = UiState::new();
2084 layout(
2085 &mut tiny,
2086 &mut tiny_state,
2087 Rect::new(0.0, 0.0, 300.0, 200.0),
2088 );
2089 assert!(
2090 !tiny_state
2091 .scroll
2092 .thumb_rects
2093 .contains_key(&tiny.computed_id)
2094 );
2095 }
2096
2097 #[test]
2098 fn layout_override_places_children_at_returned_rects() {
2099 let mut root = column((0..3).map(|i| {
2101 crate::widgets::text::text(format!("dot {i}"))
2102 .width(Size::Fixed(20.0))
2103 .height(Size::Fixed(20.0))
2104 }))
2105 .width(Size::Fixed(200.0))
2106 .height(Size::Fixed(200.0))
2107 .layout(|ctx| {
2108 ctx.children
2109 .iter()
2110 .enumerate()
2111 .map(|(i, _)| {
2112 let off = i as f32 * 30.0;
2113 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
2114 })
2115 .collect()
2116 });
2117 let mut state = UiState::new();
2118 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2119 let r0 = state.rect(&root.children[0].computed_id);
2120 let r1 = state.rect(&root.children[1].computed_id);
2121 let r2 = state.rect(&root.children[2].computed_id);
2122 assert_eq!((r0.x, r0.y), (0.0, 0.0));
2123 assert_eq!((r1.x, r1.y), (30.0, 30.0));
2124 assert_eq!((r2.x, r2.y), (60.0, 60.0));
2125 }
2126
2127 #[test]
2128 fn layout_override_rect_of_key_resolves_earlier_sibling() {
2129 use crate::tree::stack;
2135 let trigger_x = 40.0;
2136 let trigger_y = 20.0;
2137 let trigger_w = 60.0;
2138 let trigger_h = 30.0;
2139 let mut root = stack([
2140 crate::widgets::button::button("Open")
2142 .key("trig")
2143 .width(Size::Fixed(trigger_w))
2144 .height(Size::Fixed(trigger_h)),
2145 stack([crate::widgets::text::text("popover")
2148 .width(Size::Fixed(80.0))
2149 .height(Size::Fixed(20.0))])
2150 .width(Size::Fill(1.0))
2151 .height(Size::Fill(1.0))
2152 .layout(|ctx| {
2153 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
2154 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
2155 }),
2156 ])
2157 .padding(Sides::xy(trigger_x, trigger_y));
2158 let mut state = UiState::new();
2159 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2160
2161 let popover_layer = &root.children[1];
2162 let panel_id = &popover_layer.children[0].computed_id;
2163 let panel_rect = state.rect(panel_id);
2164 assert!(
2167 (panel_rect.x - trigger_x).abs() < 0.01,
2168 "popover x = {} (expected {trigger_x})",
2169 panel_rect.x,
2170 );
2171 assert!(
2172 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
2173 "popover y = {} (expected {})",
2174 panel_rect.y,
2175 trigger_y + trigger_h + 4.0,
2176 );
2177 }
2178
2179 #[test]
2180 fn layout_override_rect_of_key_returns_none_for_missing_key() {
2181 let mut root = column([crate::widgets::text::text("inner")
2182 .width(Size::Fixed(40.0))
2183 .height(Size::Fixed(20.0))])
2184 .width(Size::Fixed(200.0))
2185 .height(Size::Fixed(200.0))
2186 .layout(|ctx| {
2187 assert!((ctx.rect_of_key)("nope").is_none());
2188 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2189 });
2190 let mut state = UiState::new();
2191 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2192 }
2193
2194 #[test]
2195 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
2196 use crate::tree::stack;
2202 let mut root = stack([
2203 stack([crate::widgets::text::text("panel")
2204 .width(Size::Fixed(40.0))
2205 .height(Size::Fixed(20.0))])
2206 .width(Size::Fill(1.0))
2207 .height(Size::Fill(1.0))
2208 .layout(|ctx| {
2209 assert!(
2210 (ctx.rect_of_key)("later").is_none(),
2211 "later sibling's rect must not be available yet"
2212 );
2213 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2214 }),
2215 crate::widgets::button::button("after").key("later"),
2216 ]);
2217 let mut state = UiState::new();
2218 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2219 }
2220
2221 #[test]
2222 fn layout_override_measure_returns_intrinsic() {
2223 let mut root = column([crate::widgets::text::text("hi")
2225 .width(Size::Fixed(40.0))
2226 .height(Size::Fixed(20.0))])
2227 .width(Size::Fixed(200.0))
2228 .height(Size::Fixed(200.0))
2229 .layout(|ctx| {
2230 let (w, h) = (ctx.measure)(&ctx.children[0]);
2231 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
2232 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
2233 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
2234 });
2235 let mut state = UiState::new();
2236 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2237 let r = state.rect(&root.children[0].computed_id);
2238 assert_eq!((r.w, r.h), (40.0, 20.0));
2239 }
2240
2241 #[test]
2242 #[should_panic(expected = "returned 1 rects for 2 children")]
2243 fn layout_override_length_mismatch_panics() {
2244 let mut root = column([
2245 crate::widgets::text::text("a")
2246 .width(Size::Fixed(10.0))
2247 .height(Size::Fixed(10.0)),
2248 crate::widgets::text::text("b")
2249 .width(Size::Fixed(10.0))
2250 .height(Size::Fixed(10.0)),
2251 ])
2252 .width(Size::Fixed(200.0))
2253 .height(Size::Fixed(200.0))
2254 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
2255 let mut state = UiState::new();
2256 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2257 }
2258
2259 #[test]
2260 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
2261 fn layout_override_hug_panics() {
2262 let mut root = column([column([crate::widgets::text::text("c")])
2266 .width(Size::Hug)
2267 .height(Size::Fixed(200.0))
2268 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
2269 .width(Size::Fixed(200.0))
2270 .height(Size::Fixed(200.0));
2271 let mut state = UiState::new();
2272 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2273 }
2274
2275 #[test]
2276 fn virtual_list_realizes_only_visible_rows() {
2277 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
2281 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2282 });
2283 let mut state = UiState::new();
2284 assign_ids(&mut root);
2285 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
2286 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2287
2288 assert_eq!(
2289 root.children.len(),
2290 5,
2291 "expected 5 realized rows, got {}",
2292 root.children.len()
2293 );
2294 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
2296 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
2297 let r0 = state.rect(&root.children[0].computed_id);
2299 assert!(
2300 (r0.y - (-20.0)).abs() < 0.5,
2301 "row 2 expected y≈-20, got {}",
2302 r0.y
2303 );
2304 }
2305
2306 #[test]
2307 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
2308 let make_root = || {
2309 crate::tree::virtual_list(50, 50.0, |i| {
2310 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2311 })
2312 };
2313
2314 let mut state = UiState::new();
2315 let mut root_a = make_root();
2316 assign_ids(&mut root_a);
2317 state
2319 .scroll
2320 .offsets
2321 .insert(root_a.computed_id.clone(), 250.0);
2322 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2323 let id_at_offset_a = root_a
2324 .children
2325 .iter()
2326 .find(|c| c.key.as_deref() == Some("row-5"))
2327 .unwrap()
2328 .computed_id
2329 .clone();
2330
2331 let mut root_b = make_root();
2333 assign_ids(&mut root_b);
2334 state
2335 .scroll
2336 .offsets
2337 .insert(root_b.computed_id.clone(), 200.0);
2338 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2339 let id_at_offset_b = root_b
2340 .children
2341 .iter()
2342 .find(|c| c.key.as_deref() == Some("row-5"))
2343 .unwrap()
2344 .computed_id
2345 .clone();
2346
2347 assert_eq!(
2348 id_at_offset_a, id_at_offset_b,
2349 "row-5's computed_id changed when scroll offset moved"
2350 );
2351 }
2352
2353 #[test]
2354 fn virtual_list_clamps_overshoot_offset() {
2355 let mut root =
2357 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2358 let mut state = UiState::new();
2359 assign_ids(&mut root);
2360 state
2361 .scroll
2362 .offsets
2363 .insert(root.computed_id.clone(), 9999.0);
2364 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2365 let stored = state
2366 .scroll
2367 .offsets
2368 .get(&root.computed_id)
2369 .copied()
2370 .unwrap_or(0.0);
2371 assert!(
2372 (stored - 300.0).abs() < 0.01,
2373 "expected clamp to 300, got {stored}"
2374 );
2375 }
2376
2377 #[test]
2378 fn virtual_list_empty_count_realizes_no_children() {
2379 let mut root =
2380 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2381 let mut state = UiState::new();
2382 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2383 assert_eq!(root.children.len(), 0);
2384 }
2385
2386 #[test]
2387 #[should_panic(expected = "row_height > 0.0")]
2388 fn virtual_list_zero_row_height_panics() {
2389 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
2390 }
2391
2392 #[test]
2393 #[should_panic(expected = "Size::Hug would defeat virtualization")]
2394 fn virtual_list_hug_panics() {
2395 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
2396 crate::widgets::text::text(format!("r{i}"))
2397 })
2398 .height(Size::Hug)])
2399 .width(Size::Fixed(300.0))
2400 .height(Size::Fixed(200.0));
2401 let mut state = UiState::new();
2402 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2403 }
2404
2405 #[test]
2406 fn virtual_list_dyn_respects_per_row_fixed_heights() {
2407 let mut root = crate::tree::virtual_list_dyn(20, 50.0, |i| {
2411 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
2412 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2413 .key(format!("row-{i}"))
2414 .height(Size::Fixed(h))
2415 });
2416 let mut state = UiState::new();
2417 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2418
2419 assert_eq!(
2420 root.children.len(),
2421 4,
2422 "expected 4 realized rows, got {}",
2423 root.children.len()
2424 );
2425 let ys: Vec<f32> = root
2427 .children
2428 .iter()
2429 .map(|c| state.rect(&c.computed_id).y)
2430 .collect();
2431 assert!(
2432 (ys[0] - 0.0).abs() < 0.5,
2433 "row 0 expected y≈0, got {}",
2434 ys[0]
2435 );
2436 assert!(
2437 (ys[1] - 40.0).abs() < 0.5,
2438 "row 1 expected y≈40, got {}",
2439 ys[1]
2440 );
2441 assert!(
2442 (ys[2] - 120.0).abs() < 0.5,
2443 "row 2 expected y≈120, got {}",
2444 ys[2]
2445 );
2446 assert!(
2447 (ys[3] - 160.0).abs() < 0.5,
2448 "row 3 expected y≈160, got {}",
2449 ys[3]
2450 );
2451 }
2452
2453 #[test]
2454 fn virtual_list_dyn_caches_measured_heights() {
2455 let mut root = crate::tree::virtual_list_dyn(50, 50.0, |i| {
2459 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2460 .key(format!("row-{i}"))
2461 .height(Size::Fixed(30.0))
2462 });
2463 let mut state = UiState::new();
2464 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2465
2466 let measured = state
2467 .scroll
2468 .measured_row_heights
2469 .get(&root.computed_id)
2470 .expect("dynamic virtual list should populate the height cache");
2471 assert!(
2473 measured.len() >= 7,
2474 "expected ≥ 7 cached row heights, got {}",
2475 measured.len()
2476 );
2477 for (_, h) in measured.iter() {
2478 assert!(
2479 (h - 30.0).abs() < 0.5,
2480 "expected cached height ≈ 30, got {h}"
2481 );
2482 }
2483 }
2484
2485 #[test]
2486 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
2487 let make_root = || {
2492 crate::tree::virtual_list_dyn(20, 50.0, |i| {
2493 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2494 .key(format!("row-{i}"))
2495 .height(Size::Fixed(30.0))
2496 })
2497 };
2498 let mut state = UiState::new();
2499 let mut root = make_root();
2500 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2501
2502 let measured_count = state
2503 .scroll
2504 .measured_row_heights
2505 .get(&root.computed_id)
2506 .map(|m| m.len())
2507 .unwrap_or(0);
2508 let expected_total = measured_count as f32 * 30.0 + (20 - measured_count) as f32 * 50.0;
2509 let expected_max_offset = expected_total - 200.0;
2510
2511 state
2512 .scroll
2513 .offsets
2514 .insert(root.computed_id.clone(), 9999.0);
2515 let mut root2 = make_root();
2516 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2517 let stored = state
2518 .scroll
2519 .offsets
2520 .get(&root2.computed_id)
2521 .copied()
2522 .unwrap_or(0.0);
2523 assert!(
2524 (stored - expected_max_offset).abs() < 0.5,
2525 "expected offset clamped to {expected_max_offset}, got {stored}"
2526 );
2527 }
2528
2529 #[test]
2530 fn virtual_list_dyn_empty_count_realizes_no_children() {
2531 let mut root =
2532 crate::tree::virtual_list_dyn(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2533 let mut state = UiState::new();
2534 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2535 assert_eq!(root.children.len(), 0);
2536 }
2537
2538 #[test]
2539 #[should_panic(expected = "estimated_row_height > 0.0")]
2540 fn virtual_list_dyn_zero_estimate_panics() {
2541 let _ =
2542 crate::tree::virtual_list_dyn(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
2543 }
2544
2545 #[test]
2546 fn text_runs_constructor_shape_smoke() {
2547 let el = crate::tree::text_runs([
2548 crate::widgets::text::text("Hello, "),
2549 crate::widgets::text::text("world").bold(),
2550 crate::tree::hard_break(),
2551 crate::widgets::text::text("of text").italic(),
2552 ]);
2553 assert_eq!(el.kind, Kind::Inlines);
2554 assert_eq!(el.children.len(), 4);
2555 assert!(matches!(
2556 el.children[1].font_weight,
2557 FontWeight::Bold | FontWeight::Semibold
2558 ));
2559 assert_eq!(el.children[2].kind, Kind::HardBreak);
2560 assert!(el.children[3].text_italic);
2561 }
2562
2563 #[test]
2564 fn wrapped_text_hugs_multiline_height_from_available_width() {
2565 let mut root = column([crate::paragraph(
2566 "A longer sentence should wrap into multiple measured lines.",
2567 )])
2568 .width(Size::Fill(1.0))
2569 .height(Size::Hug);
2570
2571 let mut state = UiState::new();
2572 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
2573
2574 let child_rect = state.rect(&root.children[0].computed_id);
2575 assert_eq!(child_rect.w, 180.0);
2576 assert!(
2577 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
2578 "expected multiline paragraph height, got {}",
2579 child_rect.h
2580 );
2581 }
2582
2583 #[test]
2584 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
2585 const PANEL_W: f32 = 240.0;
2596 const PADDING: f32 = 18.0;
2597 const GAP: f32 = 12.0;
2598
2599 let panel = column([
2600 crate::paragraph(
2601 "A long enough warning paragraph that it has to wrap onto a second line \
2602 inside this narrow panel.",
2603 ),
2604 crate::widgets::button::button("OK").key("ok"),
2605 ])
2606 .width(Size::Fixed(PANEL_W))
2607 .height(Size::Hug)
2608 .padding(Sides::all(PADDING))
2609 .gap(GAP)
2610 .align(Align::Stretch);
2611
2612 let mut root = crate::stack([panel])
2613 .width(Size::Fill(1.0))
2614 .height(Size::Fill(1.0));
2615 let mut state = UiState::new();
2616 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
2617
2618 let panel_rect = state.rect(&root.children[0].computed_id);
2619 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
2620
2621 let para_rect = state.rect(&root.children[0].children[0].computed_id);
2622 let button_rect = state.rect(&root.children[0].children[1].computed_id);
2623
2624 assert!(
2627 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
2628 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
2629 got h={}",
2630 para_rect.h
2631 );
2632
2633 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
2639 assert!(
2640 (bottom_padding - PADDING).abs() < 0.5,
2641 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
2642 );
2643 }
2644
2645 #[test]
2646 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
2647 const COL_W: f32 = 600.0;
2659 const GUTTER_W: f32 = 3.0;
2660
2661 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
2662 sed do eiusmod tempor incididunt ut labore et dolore magna \
2663 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
2664 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
2665
2666 let make_row = || {
2667 let gutter = El::new(Kind::Custom("gutter"))
2668 .width(Size::Fixed(GUTTER_W))
2669 .height(Size::Fill(1.0));
2670 let body = crate::paragraph(long).width(Size::Fill(1.0));
2671 crate::row([gutter, body]).width(Size::Fill(1.0))
2672 };
2673
2674 let mut root = column([make_row(), make_row()])
2675 .width(Size::Fixed(COL_W))
2676 .height(Size::Hug)
2677 .align(Align::Stretch);
2678 let mut state = UiState::new();
2679 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
2680
2681 let row0_rect = state.rect(&root.children[0].computed_id);
2682 let row1_rect = state.rect(&root.children[1].computed_id);
2683 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
2684
2685 let line_height = crate::tokens::TEXT_SM.line_height;
2690 assert!(
2691 para0_rect.h > line_height * 1.5,
2692 "paragraph should wrap to multiple lines at ~597px wide; \
2693 got h={} (line_height={})",
2694 para0_rect.h,
2695 line_height,
2696 );
2697 assert!(
2698 row0_rect.h > line_height * 1.5,
2699 "row 0 should accommodate the wrapped paragraph height; \
2700 got h={} (line_height={})",
2701 row0_rect.h,
2702 line_height,
2703 );
2704
2705 assert!(
2707 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
2708 "row 1 starts at y={} but row 0 occupies y={}..{}",
2709 row1_rect.y,
2710 row0_rect.y,
2711 row0_rect.y + row0_rect.h,
2712 );
2713 }
2714}