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::Image => "image",
318 Kind::Surface => "surface",
319 Kind::Vector => "vector",
320 Kind::Custom(name) => name,
321 }
322}
323
324fn layout_children(node: &mut El, node_rect: Rect, ui_state: &mut UiState) {
325 if matches!(node.kind, Kind::Inlines) {
326 for c in &mut node.children {
334 ui_state.layout.computed_rects.insert(
335 c.computed_id.clone(),
336 Rect::new(node_rect.x, node_rect.y, 0.0, 0.0),
337 );
338 layout_children(c, Rect::new(node_rect.x, node_rect.y, 0.0, 0.0), ui_state);
342 }
343 return;
344 }
345 if let Some(items) = node.virtual_items.clone() {
346 layout_virtual(node, node_rect, items, ui_state);
347 return;
348 }
349 if let Some(layout_fn) = node.layout_override.clone() {
350 layout_custom(node, node_rect, layout_fn, ui_state);
351 if node.scrollable {
352 apply_scroll_offset(node, node_rect, ui_state);
353 }
354 return;
355 }
356 match node.axis {
357 Axis::Overlay => {
358 let inner = node_rect.inset(node.padding);
359 for c in &mut node.children {
360 let c_rect = overlay_rect(c, inner, node.align, node.justify);
361 ui_state
362 .layout
363 .computed_rects
364 .insert(c.computed_id.clone(), c_rect);
365 layout_children(c, c_rect, ui_state);
366 }
367 }
368 Axis::Column => layout_axis(node, node_rect, true, ui_state),
369 Axis::Row => layout_axis(node, node_rect, false, ui_state),
370 }
371 if node.scrollable {
372 apply_scroll_offset(node, node_rect, ui_state);
373 }
374}
375
376fn layout_custom(node: &mut El, node_rect: Rect, layout_fn: LayoutFn, ui_state: &mut UiState) {
377 let inner = node_rect.inset(node.padding);
378 let measure = |c: &El| intrinsic(c);
379 let key_index = &ui_state.layout.key_index;
384 let computed_rects = &ui_state.layout.computed_rects;
385 let rect_of_key = |key: &str| -> Option<Rect> {
386 let id = key_index.get(key)?;
387 computed_rects.get(id).copied()
388 };
389 let rect_of_id = |id: &str| -> Option<Rect> { computed_rects.get(id).copied() };
390 let rects = (layout_fn.0)(LayoutCtx {
391 container: inner,
392 children: &node.children,
393 measure: &measure,
394 rect_of_key: &rect_of_key,
395 rect_of_id: &rect_of_id,
396 });
397 assert_eq!(
398 rects.len(),
399 node.children.len(),
400 "LayoutFn for {:?} returned {} rects for {} children",
401 node.computed_id,
402 rects.len(),
403 node.children.len(),
404 );
405 for (c, c_rect) in node.children.iter_mut().zip(rects) {
406 ui_state
407 .layout
408 .computed_rects
409 .insert(c.computed_id.clone(), c_rect);
410 layout_children(c, c_rect, ui_state);
411 }
412}
413
414fn layout_virtual(node: &mut El, node_rect: Rect, items: VirtualItems, ui_state: &mut UiState) {
420 let inner = node_rect.inset(node.padding);
421 match items.mode {
422 VirtualMode::Fixed { row_height } => layout_virtual_fixed(
423 node,
424 inner,
425 items.count,
426 row_height,
427 items.build_row,
428 ui_state,
429 ),
430 VirtualMode::Dynamic {
431 estimated_row_height,
432 } => layout_virtual_dynamic(
433 node,
434 inner,
435 items.count,
436 estimated_row_height,
437 items.build_row,
438 ui_state,
439 ),
440 }
441}
442
443fn resolve_scroll_requests<F>(
453 node: &El,
454 inner: Rect,
455 count: usize,
456 row_extent: F,
457 ui_state: &mut UiState,
458) where
459 F: Fn(usize) -> (f32, f32),
460{
461 if ui_state.scroll.pending_requests.is_empty() {
462 return;
463 }
464 let Some(key) = node.key.as_deref() else {
465 return;
466 };
467 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
468 let (matched, remaining): (Vec<ScrollRequest>, Vec<ScrollRequest>) =
469 pending.into_iter().partition(|req| match req {
470 ScrollRequest::ToRow { list_key, .. } => list_key == key,
471 ScrollRequest::EnsureVisible { .. } => false,
474 });
475 ui_state.scroll.pending_requests = remaining;
476
477 for req in matched {
478 let ScrollRequest::ToRow { row, align, .. } = req else {
479 continue;
480 };
481 if row >= count {
482 continue;
483 }
484 let (row_top, row_h) = row_extent(row);
485 let row_bottom = row_top + row_h;
486 let viewport_h = inner.h;
487 let current = ui_state
488 .scroll
489 .offsets
490 .get(&node.computed_id)
491 .copied()
492 .unwrap_or(0.0);
493 let new_offset = match align {
494 ScrollAlignment::Start => row_top,
495 ScrollAlignment::End => row_bottom - viewport_h,
496 ScrollAlignment::Center => row_top + (row_h - viewport_h) / 2.0,
497 ScrollAlignment::Visible => {
498 if row_top < current {
499 row_top
500 } else if row_bottom > current + viewport_h {
501 row_bottom - viewport_h
502 } else {
503 continue;
504 }
505 }
506 };
507 ui_state
508 .scroll
509 .offsets
510 .insert(node.computed_id.clone(), new_offset);
511 }
512}
513
514fn write_virtual_scroll_state(node: &El, inner: Rect, total_h: f32, ui_state: &mut UiState) -> f32 {
517 let max_offset = (total_h - inner.h).max(0.0);
518 let stored = ui_state
519 .scroll
520 .offsets
521 .get(&node.computed_id)
522 .copied()
523 .unwrap_or(0.0);
524 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
525 let offset = stored.clamp(0.0, max_offset);
526 ui_state
527 .scroll
528 .offsets
529 .insert(node.computed_id.clone(), offset);
530 ui_state.scroll.metrics.insert(
531 node.computed_id.clone(),
532 crate::state::ScrollMetrics {
533 viewport_h: inner.h,
534 content_h: total_h,
535 max_offset,
536 },
537 );
538 write_thumb_rect(node, inner, total_h, max_offset, offset, ui_state);
539 offset
540}
541
542fn assign_virtual_row_id(child: &mut El, parent_id: &str, global_i: usize) {
546 let role = role_token(&child.kind);
547 let suffix = match (&child.key, role) {
548 (Some(k), r) => format!("{r}[{k}]"),
549 (None, r) => format!("{r}.{global_i}"),
550 };
551 assign_id(child, &format!("{parent_id}.{suffix}"));
552}
553
554fn layout_virtual_fixed(
555 node: &mut El,
556 inner: Rect,
557 count: usize,
558 row_height: f32,
559 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
560 ui_state: &mut UiState,
561) {
562 let total_h = count as f32 * row_height;
563 resolve_scroll_requests(
564 node,
565 inner,
566 count,
567 |i| (i as f32 * row_height, row_height),
568 ui_state,
569 );
570 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
571
572 if count == 0 {
573 node.children.clear();
574 return;
575 }
576
577 let start = (offset / row_height).floor() as usize;
579 let end = (((offset + inner.h) / row_height).ceil() as usize).min(count);
580
581 let mut realized: Vec<El> = (start..end).map(|i| (build_row)(i)).collect();
582 for (vis_i, child) in realized.iter_mut().enumerate() {
583 let global_i = start + vis_i;
584 assign_virtual_row_id(child, &node.computed_id, global_i);
585
586 let row_y = inner.y + global_i as f32 * row_height - offset;
587 let c_rect = Rect::new(inner.x, row_y, inner.w, row_height);
588 ui_state
589 .layout
590 .computed_rects
591 .insert(child.computed_id.clone(), c_rect);
592 layout_children(child, c_rect, ui_state);
593 }
594 node.children = realized;
595}
596
597fn layout_virtual_dynamic(
610 node: &mut El,
611 inner: Rect,
612 count: usize,
613 estimated_row_height: f32,
614 build_row: Arc<dyn Fn(usize) -> El + Send + Sync>,
615 ui_state: &mut UiState,
616) {
617 if let Some(map) = ui_state
619 .scroll
620 .measured_row_heights
621 .get_mut(&node.computed_id)
622 {
623 map.retain(|i, _| *i < count);
624 if map.is_empty() {
625 ui_state
626 .scroll
627 .measured_row_heights
628 .remove(&node.computed_id);
629 }
630 }
631
632 let (measured_sum, measured_count) = ui_state
633 .scroll
634 .measured_row_heights
635 .get(&node.computed_id)
636 .map(|m| (m.values().sum::<f32>(), m.len()))
637 .unwrap_or((0.0, 0));
638 let unmeasured = count.saturating_sub(measured_count);
639 let total_h = measured_sum + (unmeasured as f32) * estimated_row_height;
640
641 let has_request = node.key.as_deref().is_some_and(|k| {
647 ui_state.scroll.pending_requests.iter().any(|r| match r {
648 ScrollRequest::ToRow { list_key, .. } => list_key == k,
649 ScrollRequest::EnsureVisible { .. } => false,
650 })
651 });
652 if has_request {
653 let measured = ui_state
656 .scroll
657 .measured_row_heights
658 .get(&node.computed_id)
659 .cloned();
660 resolve_scroll_requests(
661 node,
662 inner,
663 count,
664 |target| {
665 let row_h = |i: usize| -> f32 {
666 measured
667 .as_ref()
668 .and_then(|m| m.get(&i).copied())
669 .unwrap_or(estimated_row_height)
670 };
671 let mut top = 0.0_f32;
672 for i in 0..target {
673 top += row_h(i);
674 }
675 (top, row_h(target))
676 },
677 ui_state,
678 );
679 }
680
681 let offset = write_virtual_scroll_state(node, inner, total_h, ui_state);
682
683 if count == 0 {
684 node.children.clear();
685 return;
686 }
687
688 let (start, start_y) = {
692 let measured = ui_state.scroll.measured_row_heights.get(&node.computed_id);
693 let row_h = |i: usize| -> f32 {
694 measured
695 .and_then(|m| m.get(&i).copied())
696 .unwrap_or(estimated_row_height)
697 };
698 let mut y = 0.0_f32;
699 let mut start = 0;
700 while start < count {
701 let h = row_h(start);
702 if y + h > offset {
703 break;
704 }
705 y += h;
706 start += 1;
707 }
708 (start, y)
709 };
710 let mut cursor_y = start_y;
711 let mut idx = start;
712
713 let mut realized: Vec<El> = Vec::new();
714 let mut new_measurements: Vec<(usize, f32)> = Vec::new();
715
716 while idx < count && cursor_y < offset + inner.h {
717 let mut child = (build_row)(idx);
718 assign_virtual_row_id(&mut child, &node.computed_id, idx);
719
720 let actual_h = match child.height {
723 Size::Fixed(v) => v.max(0.0),
724 Size::Hug => intrinsic_constrained(&child, Some(inner.w)).1.max(0.0),
725 Size::Fill(_) => panic!(
726 "virtual_list_dyn row {idx} on {:?} must size with Size::Fixed or Size::Hug; \
727 Size::Fill would absorb the viewport's height and break virtualization",
728 node.computed_id,
729 ),
730 };
731 new_measurements.push((idx, actual_h));
732
733 let row_y = inner.y + cursor_y - offset;
734 let c_rect = Rect::new(inner.x, row_y, inner.w, actual_h);
735 ui_state
736 .layout
737 .computed_rects
738 .insert(child.computed_id.clone(), c_rect);
739 layout_children(&mut child, c_rect, ui_state);
740
741 realized.push(child);
742 cursor_y += actual_h;
743 idx += 1;
744 }
745
746 if !new_measurements.is_empty() {
747 let entry = ui_state
748 .scroll
749 .measured_row_heights
750 .entry(node.computed_id.clone())
751 .or_default();
752 for (i, h) in new_measurements {
753 entry.insert(i, h);
754 }
755 }
756
757 node.children = realized;
758}
759
760fn apply_scroll_offset(node: &El, node_rect: Rect, ui_state: &mut UiState) {
768 let inner = node_rect.inset(node.padding);
769 if node.children.is_empty() {
770 ui_state
771 .scroll
772 .offsets
773 .insert(node.computed_id.clone(), 0.0);
774 ui_state.scroll.metrics.insert(
775 node.computed_id.clone(),
776 crate::state::ScrollMetrics {
777 viewport_h: inner.h,
778 content_h: 0.0,
779 max_offset: 0.0,
780 },
781 );
782 return;
783 }
784 let content_bottom = node
785 .children
786 .iter()
787 .map(|c| ui_state.rect(&c.computed_id).bottom())
788 .fold(f32::NEG_INFINITY, f32::max);
789 let content_h = (content_bottom - inner.y).max(0.0);
790 let max_offset = (content_h - inner.h).max(0.0);
791
792 resolve_ensure_visible_for_scroll(node, inner, content_h, ui_state);
800
801 let stored = ui_state
802 .scroll
803 .offsets
804 .get(&node.computed_id)
805 .copied()
806 .unwrap_or(0.0);
807 let stored = resolve_pin_end(node, stored, max_offset, ui_state);
808 let clamped = stored.clamp(0.0, max_offset);
809 if clamped > 0.0 {
810 for c in &node.children {
811 shift_subtree_y(c, -clamped, ui_state);
812 }
813 }
814 ui_state
815 .scroll
816 .offsets
817 .insert(node.computed_id.clone(), clamped);
818 ui_state.scroll.metrics.insert(
819 node.computed_id.clone(),
820 crate::state::ScrollMetrics {
821 viewport_h: inner.h,
822 content_h,
823 max_offset,
824 },
825 );
826
827 write_thumb_rect(node, inner, content_h, max_offset, clamped, ui_state);
828}
829
830const PIN_END_EPSILON: f32 = 0.5;
835
836fn resolve_pin_end(node: &El, stored: f32, max_offset: f32, ui_state: &mut UiState) -> f32 {
847 if !node.pin_end {
848 ui_state.scroll.pin_active.remove(&node.computed_id);
849 ui_state.scroll.pin_prev_max.remove(&node.computed_id);
850 return stored;
851 }
852 let prev_max = ui_state.scroll.pin_prev_max.get(&node.computed_id).copied();
853 let prev_active = ui_state.scroll.pin_active.get(&node.computed_id).copied();
854 let active = match prev_active {
855 None => true,
856 Some(prev) => {
857 let prev_max = prev_max.unwrap_or(0.0);
858 if prev && stored < prev_max - PIN_END_EPSILON {
859 false
862 } else if !prev && prev_max > 0.0 && stored >= prev_max - PIN_END_EPSILON {
863 true
870 } else {
871 prev
872 }
873 }
874 };
875 ui_state
876 .scroll
877 .pin_active
878 .insert(node.computed_id.clone(), active);
879 ui_state
880 .scroll
881 .pin_prev_max
882 .insert(node.computed_id.clone(), max_offset);
883 if active { max_offset } else { stored }
884}
885
886fn resolve_ensure_visible_for_scroll(
899 node: &El,
900 inner: Rect,
901 content_h: f32,
902 ui_state: &mut UiState,
903) {
904 if ui_state.scroll.pending_requests.is_empty() {
905 return;
906 }
907 let pending = std::mem::take(&mut ui_state.scroll.pending_requests);
908 let mut remaining: Vec<ScrollRequest> = Vec::with_capacity(pending.len());
909 for req in pending {
910 let ScrollRequest::EnsureVisible {
911 container_key,
912 y,
913 h,
914 } = &req
915 else {
916 remaining.push(req);
917 continue;
918 };
919 let Some(ancestor_id) = ui_state.layout.key_index.get(container_key) else {
920 remaining.push(req);
925 continue;
926 };
927 let inside = node.computed_id == *ancestor_id
930 || node
931 .computed_id
932 .strip_prefix(ancestor_id.as_str())
933 .is_some_and(|rest| rest.starts_with('.'));
934 if !inside {
935 remaining.push(req);
936 continue;
937 }
938 let current = ui_state
939 .scroll
940 .offsets
941 .get(&node.computed_id)
942 .copied()
943 .unwrap_or(0.0);
944 let target_top = *y;
945 let target_bottom = *y + *h;
946 let viewport_h = inner.h;
947 let new_offset = if target_top < current {
954 target_top
955 } else if target_bottom > current + viewport_h {
956 target_bottom - viewport_h
957 } else {
958 continue;
963 };
964 let max = (content_h - viewport_h).max(0.0);
968 let new_offset = new_offset.clamp(0.0, max);
969 ui_state
970 .scroll
971 .offsets
972 .insert(node.computed_id.clone(), new_offset);
973 }
974 ui_state.scroll.pending_requests = remaining;
975}
976
977fn write_thumb_rect(
985 node: &El,
986 inner: Rect,
987 content_h: f32,
988 max_offset: f32,
989 offset: f32,
990 ui_state: &mut UiState,
991) {
992 if !node.scrollbar || max_offset <= 0.0 || inner.h <= 0.0 || content_h <= 0.0 {
993 return;
994 }
995 let thumb_w = crate::tokens::SCROLLBAR_THUMB_WIDTH;
996 let track_w = crate::tokens::SCROLLBAR_HITBOX_WIDTH;
997 let track_inset = crate::tokens::SCROLLBAR_TRACK_INSET;
998 let min_thumb_h = crate::tokens::SCROLLBAR_THUMB_MIN_H;
999 let thumb_h = ((inner.h * inner.h / content_h).max(min_thumb_h)).min(inner.h);
1000 let track_remaining = (inner.h - thumb_h).max(0.0);
1001 let thumb_y = inner.y + track_remaining * (offset / max_offset);
1002 let thumb_x = inner.right() - thumb_w - track_inset;
1003 let track_x = inner.right() - track_w - track_inset;
1004 ui_state.scroll.thumb_rects.insert(
1005 node.computed_id.clone(),
1006 Rect::new(thumb_x, thumb_y, thumb_w, thumb_h),
1007 );
1008 ui_state.scroll.thumb_tracks.insert(
1009 node.computed_id.clone(),
1010 Rect::new(track_x, inner.y, track_w, inner.h),
1011 );
1012}
1013
1014fn shift_subtree_y(node: &El, dy: f32, ui_state: &mut UiState) {
1015 if let Some(rect) = ui_state.layout.computed_rects.get_mut(&node.computed_id) {
1016 rect.y += dy;
1017 }
1018 for c in &node.children {
1019 shift_subtree_y(c, dy, ui_state);
1020 }
1021}
1022
1023fn layout_axis(node: &mut El, node_rect: Rect, vertical: bool, ui_state: &mut UiState) {
1024 let inner = node_rect.inset(node.padding);
1025 let n = node.children.len();
1026 if n == 0 {
1027 return;
1028 }
1029
1030 let total_gap = node.gap * n.saturating_sub(1) as f32;
1031 let main_extent = if vertical { inner.h } else { inner.w };
1032 let cross_extent = if vertical { inner.w } else { inner.h };
1033
1034 let intrinsics: Vec<(f32, f32)> = {
1035 crate::profile_span!("layout::axis::intrinsics");
1036 node.children
1037 .iter()
1038 .map(|c| child_intrinsic(c, vertical, cross_extent, node.align))
1039 .collect()
1040 };
1041
1042 let mut consumed = 0.0;
1043 let mut fill_weight_total = 0.0;
1044 for (c, (iw, ih)) in node.children.iter().zip(intrinsics.iter()) {
1045 match main_size_of(c, *iw, *ih, vertical) {
1046 MainSize::Resolved(v) => consumed += v,
1047 MainSize::Fill(w) => fill_weight_total += w.max(0.001),
1048 }
1049 }
1050 let remaining = (main_extent - consumed - total_gap).max(0.0);
1051
1052 let free_after_used = if fill_weight_total == 0.0 {
1056 remaining
1057 } else {
1058 0.0
1059 };
1060 let mut cursor = match node.justify {
1061 Justify::Start => 0.0,
1062 Justify::Center => free_after_used * 0.5,
1063 Justify::End => free_after_used,
1064 Justify::SpaceBetween => 0.0,
1065 };
1066 let between_extra =
1067 if matches!(node.justify, Justify::SpaceBetween) && n > 1 && fill_weight_total == 0.0 {
1068 remaining / (n - 1) as f32
1069 } else {
1070 0.0
1071 };
1072
1073 crate::profile_span!("layout::axis::place");
1074 for (i, (c, (iw, ih))) in node.children.iter_mut().zip(intrinsics).enumerate() {
1075 let main_size = match main_size_of(c, iw, ih, vertical) {
1076 MainSize::Resolved(v) => v,
1077 MainSize::Fill(w) => remaining * w.max(0.001) / fill_weight_total.max(0.001),
1078 };
1079
1080 let cross_intent = if vertical { c.width } else { c.height };
1081 let cross_intrinsic = if vertical { iw } else { ih };
1082 let cross_size = match cross_intent {
1091 Size::Fixed(v) => v,
1092 Size::Hug | Size::Fill(_) => match node.align {
1093 Align::Stretch => cross_extent,
1094 Align::Start | Align::Center | Align::End => cross_intrinsic,
1095 },
1096 };
1097
1098 let cross_off = match node.align {
1099 Align::Start | Align::Stretch => 0.0,
1100 Align::Center => (cross_extent - cross_size) * 0.5,
1101 Align::End => cross_extent - cross_size,
1102 };
1103
1104 let c_rect = if vertical {
1105 Rect::new(inner.x + cross_off, inner.y + cursor, cross_size, main_size)
1106 } else {
1107 Rect::new(inner.x + cursor, inner.y + cross_off, main_size, cross_size)
1108 };
1109 ui_state
1110 .layout
1111 .computed_rects
1112 .insert(c.computed_id.clone(), c_rect);
1113 layout_children(c, c_rect, ui_state);
1114
1115 cursor += main_size + node.gap + if i + 1 < n { between_extra } else { 0.0 };
1116 }
1117}
1118
1119enum MainSize {
1120 Resolved(f32),
1121 Fill(f32),
1122}
1123
1124fn main_size_of(c: &El, iw: f32, ih: f32, vertical: bool) -> MainSize {
1125 let s = if vertical { c.height } else { c.width };
1126 let intr = if vertical { ih } else { iw };
1127 match s {
1128 Size::Fixed(v) => MainSize::Resolved(v),
1129 Size::Hug => MainSize::Resolved(intr),
1130 Size::Fill(w) => MainSize::Fill(w),
1131 }
1132}
1133
1134fn child_intrinsic(
1135 c: &El,
1136 vertical: bool,
1137 parent_cross_extent: f32,
1138 parent_align: Align,
1139) -> (f32, f32) {
1140 if !vertical {
1141 return intrinsic(c);
1142 }
1143 let available_width = match c.width {
1144 Size::Fixed(v) => Some(v),
1145 Size::Fill(_) => Some(parent_cross_extent),
1146 Size::Hug => match parent_align {
1147 Align::Stretch => Some(parent_cross_extent),
1148 Align::Start | Align::Center | Align::End => Some(parent_cross_extent),
1149 },
1150 };
1151 intrinsic_constrained(c, available_width)
1152}
1153
1154fn overlay_rect(c: &El, parent: Rect, align: Align, justify: Justify) -> Rect {
1155 let constrained_width = match c.width {
1162 Size::Fixed(v) => Some(v),
1163 Size::Fill(_) | Size::Hug => Some(parent.w),
1164 };
1165 let (iw, ih) = intrinsic_constrained(c, constrained_width);
1166 let w = match c.width {
1167 Size::Fixed(v) => v,
1168 Size::Hug => iw.min(parent.w),
1169 Size::Fill(_) => parent.w,
1170 };
1171 let h = match c.height {
1172 Size::Fixed(v) => v,
1173 Size::Hug => ih.min(parent.h),
1174 Size::Fill(_) => parent.h,
1175 };
1176 let x = match align {
1177 Align::Start | Align::Stretch => parent.x,
1178 Align::Center => parent.x + (parent.w - w) * 0.5,
1179 Align::End => parent.right() - w,
1180 };
1181 let y = match justify {
1182 Justify::Start | Justify::SpaceBetween => parent.y,
1183 Justify::Center => parent.y + (parent.h - h) * 0.5,
1184 Justify::End => parent.bottom() - h,
1185 };
1186 Rect::new(x, y, w, h)
1187}
1188
1189pub fn intrinsic(c: &El) -> (f32, f32) {
1191 intrinsic_constrained(c, None)
1192}
1193
1194fn intrinsic_constrained(c: &El, available_width: Option<f32>) -> (f32, f32) {
1195 if c.layout_override.is_some() {
1196 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1201 panic!(
1202 "layout_override on {:?} requires Size::Fixed or Size::Fill on both axes; \
1203 Size::Hug is not supported for custom layouts",
1204 c.computed_id,
1205 );
1206 }
1207 return apply_min(c, 0.0, 0.0);
1208 }
1209 if c.virtual_items.is_some() {
1210 if matches!(c.width, Size::Hug) || matches!(c.height, Size::Hug) {
1215 panic!(
1216 "virtual_list on {:?} requires Size::Fixed or Size::Fill on both axes; \
1217 Size::Hug would defeat virtualization",
1218 c.computed_id,
1219 );
1220 }
1221 return apply_min(c, 0.0, 0.0);
1222 }
1223 if matches!(c.kind, Kind::Inlines) {
1224 return inline_paragraph_intrinsic(c, available_width);
1225 }
1226 if matches!(c.kind, Kind::HardBreak) {
1227 return apply_min(c, 0.0, 0.0);
1231 }
1232 if c.icon.is_some() {
1233 return apply_min(
1234 c,
1235 c.font_size + c.padding.left + c.padding.right,
1236 c.font_size + c.padding.top + c.padding.bottom,
1237 );
1238 }
1239 if let Some(img) = &c.image {
1240 let w = img.width() as f32 + c.padding.left + c.padding.right;
1244 let h = img.height() as f32 + c.padding.top + c.padding.bottom;
1245 return apply_min(c, w, h);
1246 }
1247 if let Some(text) = &c.text {
1248 let unwrapped = text_metrics::layout_text_with_family(
1249 text,
1250 c.font_size,
1251 c.font_family,
1252 c.font_weight,
1253 c.font_mono,
1254 TextWrap::NoWrap,
1255 None,
1256 );
1257 let content_available = match c.text_wrap {
1258 TextWrap::NoWrap => None,
1259 TextWrap::Wrap => available_width
1260 .or(match c.width {
1261 Size::Fixed(v) => Some(v),
1262 Size::Fill(_) | Size::Hug => None,
1263 })
1264 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1265 };
1266 let display = display_text_for_measure(c, text, content_available);
1267 let layout = text_metrics::layout_text_with_line_height_and_family(
1268 &display,
1269 c.font_size,
1270 c.line_height,
1271 c.font_family,
1272 c.font_weight,
1273 c.font_mono,
1274 c.text_wrap,
1275 content_available,
1276 );
1277 let w = content_available
1278 .map(|available| unwrapped.width.min(available) + c.padding.left + c.padding.right)
1279 .unwrap_or(layout.width + c.padding.left + c.padding.right);
1280 let h = layout.height + c.padding.top + c.padding.bottom;
1281 return apply_min(c, w, h);
1282 }
1283 match c.axis {
1284 Axis::Overlay => {
1285 let mut w: f32 = 0.0;
1286 let mut h: f32 = 0.0;
1287 for ch in &c.children {
1288 let child_available =
1289 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1290 let (cw, chh) = intrinsic_constrained(ch, child_available);
1291 w = w.max(cw);
1292 h = h.max(chh);
1293 }
1294 apply_min(
1295 c,
1296 w + c.padding.left + c.padding.right,
1297 h + c.padding.top + c.padding.bottom,
1298 )
1299 }
1300 Axis::Column => {
1301 let mut w: f32 = 0.0;
1302 let mut h: f32 = c.padding.top + c.padding.bottom;
1303 let n = c.children.len();
1304 let child_available =
1305 available_width.map(|w| (w - c.padding.left - c.padding.right).max(0.0));
1306 for (i, ch) in c.children.iter().enumerate() {
1307 let (cw, chh) = intrinsic_constrained(ch, child_available);
1308 w = w.max(cw);
1309 h += chh;
1310 if i + 1 < n {
1311 h += c.gap;
1312 }
1313 }
1314 apply_min(c, w + c.padding.left + c.padding.right, h)
1315 }
1316 Axis::Row => {
1317 let n = c.children.len();
1327 let total_gap = c.gap * n.saturating_sub(1) as f32;
1328 let inner_available = available_width
1329 .map(|w| (w - c.padding.left - c.padding.right - total_gap).max(0.0));
1330
1331 let mut consumed: f32 = 0.0;
1337 let mut fill_weight_total: f32 = 0.0;
1338 let mut sizes: Vec<Option<(f32, f32)>> = Vec::with_capacity(n);
1339 for ch in &c.children {
1340 match ch.width {
1341 Size::Fill(w) => {
1342 fill_weight_total += w.max(0.001);
1343 sizes.push(None);
1344 }
1345 _ => {
1346 let (cw, chh) = intrinsic(ch);
1347 consumed += cw;
1348 sizes.push(Some((cw, chh)));
1349 }
1350 }
1351 }
1352
1353 let fill_remaining = inner_available.map(|av| (av - consumed).max(0.0));
1361 let mut w_total: f32 = c.padding.left + c.padding.right;
1362 let mut h_max: f32 = 0.0;
1363 for (i, (ch, slot)) in c.children.iter().zip(sizes).enumerate() {
1364 let (cw, chh) = match slot {
1365 Some(rc) => rc,
1366 None => match (fill_remaining, fill_weight_total > 0.0) {
1367 (Some(av), true) => {
1368 let weight = match ch.width {
1369 Size::Fill(w) => w.max(0.001),
1370 _ => 1.0,
1371 };
1372 intrinsic_constrained(ch, Some(av * weight / fill_weight_total))
1373 }
1374 _ => intrinsic(ch),
1375 },
1376 };
1377 w_total += cw;
1378 if i + 1 < n {
1379 w_total += c.gap;
1380 }
1381 h_max = h_max.max(chh);
1382 }
1383 apply_min(c, w_total, h_max + c.padding.top + c.padding.bottom)
1384 }
1385 }
1386}
1387
1388pub(crate) fn text_layout(
1389 c: &El,
1390 available_width: Option<f32>,
1391) -> Option<text_metrics::TextLayout> {
1392 let text = c.text.as_ref()?;
1393 let content_available = match c.text_wrap {
1394 TextWrap::NoWrap => None,
1395 TextWrap::Wrap => available_width
1396 .or(match c.width {
1397 Size::Fixed(v) => Some(v),
1398 Size::Fill(_) | Size::Hug => None,
1399 })
1400 .map(|w| (w - c.padding.left - c.padding.right).max(1.0)),
1401 };
1402 let display = display_text_for_measure(c, text, content_available);
1403 Some(text_metrics::layout_text_with_line_height_and_family(
1404 &display,
1405 c.font_size,
1406 c.line_height,
1407 c.font_family,
1408 c.font_weight,
1409 c.font_mono,
1410 c.text_wrap,
1411 content_available,
1412 ))
1413}
1414
1415fn display_text_for_measure(c: &El, text: &str, available_width: Option<f32>) -> String {
1416 if let (TextWrap::Wrap, Some(max_lines), Some(width)) =
1417 (c.text_wrap, c.text_max_lines, available_width)
1418 {
1419 text_metrics::clamp_text_to_lines_with_family(
1420 text,
1421 c.font_size,
1422 c.font_family,
1423 c.font_weight,
1424 c.font_mono,
1425 width,
1426 max_lines,
1427 )
1428 } else {
1429 text.to_string()
1430 }
1431}
1432
1433fn apply_min(c: &El, mut w: f32, mut h: f32) -> (f32, f32) {
1434 if let Size::Fixed(v) = c.width {
1435 w = v;
1436 }
1437 if let Size::Fixed(v) = c.height {
1438 h = v;
1439 }
1440 (w, h)
1441}
1442
1443fn inline_paragraph_intrinsic(node: &El, available_width: Option<f32>) -> (f32, f32) {
1456 let concat = concat_inline_text(&node.children);
1457 let size = inline_paragraph_size(node);
1458 let line_height = inline_paragraph_line_height(node);
1459 let unwrapped = text_metrics::layout_text_with_line_height_and_family(
1460 &concat,
1461 size,
1462 line_height,
1463 node.font_family,
1464 FontWeight::Regular,
1465 false,
1466 TextWrap::NoWrap,
1467 None,
1468 );
1469 let content_available = match node.text_wrap {
1470 TextWrap::NoWrap => None,
1471 TextWrap::Wrap => available_width
1472 .or(match node.width {
1473 Size::Fixed(v) => Some(v),
1474 Size::Fill(_) | Size::Hug => None,
1475 })
1476 .map(|w| (w - node.padding.left - node.padding.right).max(1.0)),
1477 };
1478 let layout = text_metrics::layout_text_with_line_height_and_family(
1479 &concat,
1480 size,
1481 line_height,
1482 node.font_family,
1483 FontWeight::Regular,
1484 false,
1485 node.text_wrap,
1486 content_available,
1487 );
1488 let w = content_available
1489 .map(|av| unwrapped.width.min(av) + node.padding.left + node.padding.right)
1490 .unwrap_or(layout.width + node.padding.left + node.padding.right);
1491 let h = layout.height + node.padding.top + node.padding.bottom;
1492 apply_min(node, w, h)
1493}
1494
1495fn concat_inline_text(children: &[El]) -> String {
1502 let mut s = String::new();
1503 for c in children {
1504 match c.kind {
1505 Kind::Text => {
1506 if let Some(t) = &c.text {
1507 s.push_str(t);
1508 }
1509 }
1510 Kind::HardBreak => s.push('\n'),
1511 _ => {}
1512 }
1513 }
1514 s
1515}
1516
1517fn inline_paragraph_size(node: &El) -> f32 {
1521 let mut size: f32 = node.font_size;
1522 for c in &node.children {
1523 if matches!(c.kind, Kind::Text) {
1524 size = size.max(c.font_size);
1525 }
1526 }
1527 size
1528}
1529
1530fn inline_paragraph_line_height(node: &El) -> f32 {
1531 let mut line_height: f32 = node.line_height;
1532 let mut max_size: f32 = node.font_size;
1533 for c in &node.children {
1534 if matches!(c.kind, Kind::Text) && c.font_size >= max_size {
1535 max_size = c.font_size;
1536 line_height = c.line_height;
1537 }
1538 }
1539 line_height
1540}
1541
1542#[cfg(test)]
1543mod tests {
1544 use super::*;
1545 use crate::state::UiState;
1546
1547 #[test]
1552 fn align_center_shrinks_fill_child_to_intrinsic() {
1553 let mut root = column([crate::row([crate::widgets::text::text("hi")
1557 .width(Size::Fixed(40.0))
1558 .height(Size::Fixed(20.0))])])
1559 .align(Align::Center)
1560 .width(Size::Fixed(200.0))
1561 .height(Size::Fixed(100.0));
1562 let mut state = UiState::new();
1563 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1564 let row_rect = state.rect(&root.children[0].computed_id);
1565 assert!(
1568 (row_rect.x - 80.0).abs() < 0.5,
1569 "expected x≈80 (centered), got {}",
1570 row_rect.x
1571 );
1572 assert!(
1573 (row_rect.w - 40.0).abs() < 0.5,
1574 "expected w≈40 (shrunk to intrinsic), got {}",
1575 row_rect.w
1576 );
1577 }
1578
1579 #[test]
1582 fn align_stretch_preserves_fill_stretch() {
1583 let mut root = column([crate::row([crate::widgets::text::text("hi")
1584 .width(Size::Fixed(40.0))
1585 .height(Size::Fixed(20.0))])])
1586 .align(Align::Stretch)
1587 .width(Size::Fixed(200.0))
1588 .height(Size::Fixed(100.0));
1589 let mut state = UiState::new();
1590 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1591 let row_rect = state.rect(&root.children[0].computed_id);
1592 assert!(
1593 (row_rect.x - 0.0).abs() < 0.5 && (row_rect.w - 200.0).abs() < 0.5,
1594 "expected stretched (x=0, w=200), got x={} w={}",
1595 row_rect.x,
1596 row_rect.w
1597 );
1598 }
1599
1600 #[test]
1603 fn justify_center_centers_hug_children() {
1604 let mut root = column([crate::widgets::text::text("hi")
1605 .width(Size::Fixed(40.0))
1606 .height(Size::Fixed(20.0))])
1607 .justify(Justify::Center)
1608 .height(Size::Fill(1.0));
1609 let mut state = UiState::new();
1610 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
1611 let child_rect = state.rect(&root.children[0].computed_id);
1612 assert!(
1614 (child_rect.y - 40.0).abs() < 0.5,
1615 "expected y≈40, got {}",
1616 child_rect.y
1617 );
1618 }
1619
1620 #[test]
1621 fn justify_end_pushes_to_bottom() {
1622 let mut root = column([crate::widgets::text::text("hi")
1623 .width(Size::Fixed(40.0))
1624 .height(Size::Fixed(20.0))])
1625 .justify(Justify::End)
1626 .height(Size::Fill(1.0));
1627 let mut state = UiState::new();
1628 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 100.0));
1629 let child_rect = state.rect(&root.children[0].computed_id);
1630 assert!(
1631 (child_rect.y - 80.0).abs() < 0.5,
1632 "expected y≈80, got {}",
1633 child_rect.y
1634 );
1635 }
1636
1637 #[test]
1641 fn justify_space_between_distributes_evenly() {
1642 let row_child = || {
1643 crate::widgets::text::text("x")
1644 .width(Size::Fixed(20.0))
1645 .height(Size::Fixed(20.0))
1646 };
1647 let mut root = column([row_child(), row_child(), row_child()])
1648 .justify(Justify::SpaceBetween)
1649 .height(Size::Fixed(200.0));
1650 let mut state = UiState::new();
1651 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 200.0));
1652 let y0 = state.rect(&root.children[0].computed_id).y;
1655 let y1 = state.rect(&root.children[1].computed_id).y;
1656 let y2 = state.rect(&root.children[2].computed_id).y;
1657 assert!(
1658 y0.abs() < 0.5,
1659 "first child should be flush at y=0, got {y0}"
1660 );
1661 assert!(
1662 (y1 - 90.0).abs() < 0.5,
1663 "middle child should be at y≈90, got {y1}"
1664 );
1665 assert!(
1666 (y2 - 180.0).abs() < 0.5,
1667 "last child should be flush at y≈180, got {y2}"
1668 );
1669 }
1670
1671 #[test]
1675 fn fill_weight_distributes_proportionally() {
1676 let big = crate::widgets::text::text("big")
1677 .width(Size::Fixed(40.0))
1678 .height(Size::Fill(2.0));
1679 let small = crate::widgets::text::text("small")
1680 .width(Size::Fixed(40.0))
1681 .height(Size::Fill(1.0));
1682 let mut root = column([big, small]).height(Size::Fixed(300.0));
1683 let mut state = UiState::new();
1684 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 100.0, 300.0));
1685 let big_h = state.rect(&root.children[0].computed_id).h;
1687 let small_h = state.rect(&root.children[1].computed_id).h;
1688 assert!(
1689 (big_h - 200.0).abs() < 0.5,
1690 "Fill(2.0) should claim 2/3 of 300 ≈ 200, got {big_h}"
1691 );
1692 assert!(
1693 (small_h - 100.0).abs() < 0.5,
1694 "Fill(1.0) should claim 1/3 of 300 ≈ 100, got {small_h}"
1695 );
1696 }
1697
1698 #[test]
1702 fn padding_on_hug_includes_in_intrinsic() {
1703 let root = column([crate::widgets::text::text("x")
1704 .width(Size::Fixed(40.0))
1705 .height(Size::Fixed(40.0))])
1706 .padding(Sides::all(20.0));
1707 let (w, h) = intrinsic(&root);
1708 assert!((w - 80.0).abs() < 0.5, "expected intrinsic w≈80, got {w}");
1710 assert!((h - 80.0).abs() < 0.5, "expected intrinsic h≈80, got {h}");
1711 }
1712
1713 #[test]
1717 fn align_end_pins_to_cross_axis_far_edge() {
1718 let mut root = crate::row([crate::widgets::text::text("hi")
1719 .width(Size::Fixed(40.0))
1720 .height(Size::Fixed(20.0))])
1721 .align(Align::End)
1722 .width(Size::Fixed(200.0))
1723 .height(Size::Fixed(100.0));
1724 let mut state = UiState::new();
1725 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
1726 let child_rect = state.rect(&root.children[0].computed_id);
1727 assert!(
1729 (child_rect.y - 80.0).abs() < 0.5,
1730 "expected y≈80 (pinned to bottom), got {}",
1731 child_rect.y
1732 );
1733 }
1734
1735 #[test]
1736 fn overlay_can_center_hug_child() {
1737 let mut root = stack([crate::titled_card("Dialog", [crate::text("Body")])
1738 .width(Size::Fixed(200.0))
1739 .height(Size::Hug)])
1740 .align(Align::Center)
1741 .justify(Justify::Center);
1742 let mut state = UiState::new();
1743 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 600.0, 400.0));
1744 let child_rect = state.rect(&root.children[0].computed_id);
1745 assert!(
1746 (child_rect.x - 200.0).abs() < 0.5,
1747 "expected x≈200, got {}",
1748 child_rect.x
1749 );
1750 assert!(
1751 child_rect.y > 100.0 && child_rect.y < 200.0,
1752 "expected centered y, got {}",
1753 child_rect.y
1754 );
1755 }
1756
1757 #[test]
1758 fn scroll_offset_translates_children_and_clamps_to_content() {
1759 let mut root = scroll(
1763 (0..6)
1764 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1765 )
1766 .key("list")
1767 .gap(12.0)
1768 .height(Size::Fixed(200.0));
1769 let mut state = UiState::new();
1770 assign_ids(&mut root);
1771 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
1772
1773 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1774
1775 let stored = state
1777 .scroll
1778 .offsets
1779 .get(&root.computed_id)
1780 .copied()
1781 .unwrap_or(0.0);
1782 assert!(
1783 (stored - 80.0).abs() < 0.01,
1784 "offset clamped unexpectedly: {stored}"
1785 );
1786 let c0 = state.rect(&root.children[0].computed_id);
1788 assert!(
1789 (c0.y - (-80.0)).abs() < 0.01,
1790 "child 0 y = {} (expected -80)",
1791 c0.y
1792 );
1793 state
1795 .scroll
1796 .offsets
1797 .insert(root.computed_id.clone(), 9999.0);
1798 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1799 let stored = state
1800 .scroll
1801 .offsets
1802 .get(&root.computed_id)
1803 .copied()
1804 .unwrap_or(0.0);
1805 assert!(
1806 (stored - 160.0).abs() < 0.01,
1807 "overshoot clamped to {stored}"
1808 );
1809 let mut tiny =
1811 scroll([crate::widgets::text::text("just one row").height(Size::Fixed(20.0))])
1812 .height(Size::Fixed(200.0));
1813 let mut tiny_state = UiState::new();
1814 assign_ids(&mut tiny);
1815 tiny_state
1816 .scroll
1817 .offsets
1818 .insert(tiny.computed_id.clone(), 50.0);
1819 layout(
1820 &mut tiny,
1821 &mut tiny_state,
1822 Rect::new(0.0, 0.0, 300.0, 200.0),
1823 );
1824 assert_eq!(
1825 tiny_state
1826 .scroll
1827 .offsets
1828 .get(&tiny.computed_id)
1829 .copied()
1830 .unwrap_or(0.0),
1831 0.0
1832 );
1833 }
1834
1835 #[test]
1836 fn scrollbar_thumb_size_and_position_track_overflow() {
1837 let mut root = scroll(
1840 (0..6)
1841 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1842 )
1843 .gap(12.0)
1844 .height(Size::Fixed(200.0));
1845 let mut state = UiState::new();
1846 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1847
1848 let metrics = state
1849 .scroll
1850 .metrics
1851 .get(&root.computed_id)
1852 .copied()
1853 .expect("scrollable should have metrics");
1854 assert!((metrics.viewport_h - 200.0).abs() < 0.01);
1855 assert!((metrics.content_h - 360.0).abs() < 0.01);
1856 assert!((metrics.max_offset - 160.0).abs() < 0.01);
1857
1858 let thumb = state
1859 .scroll
1860 .thumb_rects
1861 .get(&root.computed_id)
1862 .copied()
1863 .expect("scrollable with scrollbar() and overflow gets a thumb");
1864 assert!((thumb.h - 111.111).abs() < 0.5, "thumb h = {}", thumb.h);
1866 assert!((thumb.w - crate::tokens::SCROLLBAR_THUMB_WIDTH).abs() < 0.01);
1867 assert!(thumb.y.abs() < 0.01);
1869 assert!(
1871 (thumb.x + thumb.w + crate::tokens::SCROLLBAR_TRACK_INSET - 300.0).abs() < 0.01,
1872 "thumb anchored at {} (expected {})",
1873 thumb.x,
1874 300.0 - thumb.w - crate::tokens::SCROLLBAR_TRACK_INSET
1875 );
1876
1877 state.scroll.offsets.insert(root.computed_id.clone(), 80.0);
1879 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1880 let thumb = state
1881 .scroll
1882 .thumb_rects
1883 .get(&root.computed_id)
1884 .copied()
1885 .unwrap();
1886 let track_remaining = 200.0 - thumb.h;
1887 let expected_y = track_remaining * (80.0 / 160.0);
1888 assert!(
1889 (thumb.y - expected_y).abs() < 0.5,
1890 "thumb at half-scroll y = {} (expected {expected_y})",
1891 thumb.y,
1892 );
1893 }
1894
1895 #[test]
1896 fn scrollbar_track_is_wider_than_thumb_and_full_height() {
1897 let mut root = scroll(
1901 (0..6)
1902 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1903 )
1904 .gap(12.0)
1905 .height(Size::Fixed(200.0));
1906 let mut state = UiState::new();
1907 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
1908
1909 let thumb = state
1910 .scroll
1911 .thumb_rects
1912 .get(&root.computed_id)
1913 .copied()
1914 .unwrap();
1915 let track = state
1916 .scroll
1917 .thumb_tracks
1918 .get(&root.computed_id)
1919 .copied()
1920 .unwrap();
1921 assert!(track.w > thumb.w, "track.w {} thumb.w {}", track.w, thumb.w);
1923 assert!(
1924 (track.right() - thumb.right()).abs() < 0.01,
1925 "track and thumb must share the right edge",
1926 );
1927 assert!(
1930 (track.h - 200.0).abs() < 0.01,
1931 "track height = {} (expected 200)",
1932 track.h,
1933 );
1934 }
1935
1936 #[test]
1937 fn scrollbar_thumb_absent_when_disabled_or_no_overflow() {
1938 let mut suppressed = scroll(
1940 (0..6)
1941 .map(|i| crate::widgets::text::text(format!("row {i}")).height(Size::Fixed(50.0))),
1942 )
1943 .no_scrollbar()
1944 .height(Size::Fixed(200.0));
1945 let mut state = UiState::new();
1946 layout(
1947 &mut suppressed,
1948 &mut state,
1949 Rect::new(0.0, 0.0, 300.0, 200.0),
1950 );
1951 assert!(
1952 !state
1953 .scroll
1954 .thumb_rects
1955 .contains_key(&suppressed.computed_id)
1956 );
1957
1958 let mut tiny = scroll([crate::widgets::text::text("one row").height(Size::Fixed(20.0))])
1960 .height(Size::Fixed(200.0));
1961 let mut tiny_state = UiState::new();
1962 layout(
1963 &mut tiny,
1964 &mut tiny_state,
1965 Rect::new(0.0, 0.0, 300.0, 200.0),
1966 );
1967 assert!(
1968 !tiny_state
1969 .scroll
1970 .thumb_rects
1971 .contains_key(&tiny.computed_id)
1972 );
1973 }
1974
1975 #[test]
1976 fn layout_override_places_children_at_returned_rects() {
1977 let mut root = column((0..3).map(|i| {
1979 crate::widgets::text::text(format!("dot {i}"))
1980 .width(Size::Fixed(20.0))
1981 .height(Size::Fixed(20.0))
1982 }))
1983 .width(Size::Fixed(200.0))
1984 .height(Size::Fixed(200.0))
1985 .layout(|ctx| {
1986 ctx.children
1987 .iter()
1988 .enumerate()
1989 .map(|(i, _)| {
1990 let off = i as f32 * 30.0;
1991 Rect::new(ctx.container.x + off, ctx.container.y + off, 20.0, 20.0)
1992 })
1993 .collect()
1994 });
1995 let mut state = UiState::new();
1996 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
1997 let r0 = state.rect(&root.children[0].computed_id);
1998 let r1 = state.rect(&root.children[1].computed_id);
1999 let r2 = state.rect(&root.children[2].computed_id);
2000 assert_eq!((r0.x, r0.y), (0.0, 0.0));
2001 assert_eq!((r1.x, r1.y), (30.0, 30.0));
2002 assert_eq!((r2.x, r2.y), (60.0, 60.0));
2003 }
2004
2005 #[test]
2006 fn layout_override_rect_of_key_resolves_earlier_sibling() {
2007 use crate::tree::stack;
2013 let trigger_x = 40.0;
2014 let trigger_y = 20.0;
2015 let trigger_w = 60.0;
2016 let trigger_h = 30.0;
2017 let mut root = stack([
2018 crate::widgets::button::button("Open")
2020 .key("trig")
2021 .width(Size::Fixed(trigger_w))
2022 .height(Size::Fixed(trigger_h)),
2023 stack([crate::widgets::text::text("popover")
2026 .width(Size::Fixed(80.0))
2027 .height(Size::Fixed(20.0))])
2028 .width(Size::Fill(1.0))
2029 .height(Size::Fill(1.0))
2030 .layout(|ctx| {
2031 let trig = (ctx.rect_of_key)("trig").expect("trigger laid out");
2032 vec![Rect::new(trig.x, trig.bottom() + 4.0, 80.0, 20.0)]
2033 }),
2034 ])
2035 .padding(Sides::xy(trigger_x, trigger_y));
2036 let mut state = UiState::new();
2037 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2038
2039 let popover_layer = &root.children[1];
2040 let panel_id = &popover_layer.children[0].computed_id;
2041 let panel_rect = state.rect(panel_id);
2042 assert!(
2045 (panel_rect.x - trigger_x).abs() < 0.01,
2046 "popover x = {} (expected {trigger_x})",
2047 panel_rect.x,
2048 );
2049 assert!(
2050 (panel_rect.y - (trigger_y + trigger_h + 4.0)).abs() < 0.01,
2051 "popover y = {} (expected {})",
2052 panel_rect.y,
2053 trigger_y + trigger_h + 4.0,
2054 );
2055 }
2056
2057 #[test]
2058 fn layout_override_rect_of_key_returns_none_for_missing_key() {
2059 let mut root = column([crate::widgets::text::text("inner")
2060 .width(Size::Fixed(40.0))
2061 .height(Size::Fixed(20.0))])
2062 .width(Size::Fixed(200.0))
2063 .height(Size::Fixed(200.0))
2064 .layout(|ctx| {
2065 assert!((ctx.rect_of_key)("nope").is_none());
2066 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2067 });
2068 let mut state = UiState::new();
2069 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2070 }
2071
2072 #[test]
2073 fn layout_override_rect_of_key_returns_none_for_later_sibling() {
2074 use crate::tree::stack;
2080 let mut root = stack([
2081 stack([crate::widgets::text::text("panel")
2082 .width(Size::Fixed(40.0))
2083 .height(Size::Fixed(20.0))])
2084 .width(Size::Fill(1.0))
2085 .height(Size::Fill(1.0))
2086 .layout(|ctx| {
2087 assert!(
2088 (ctx.rect_of_key)("later").is_none(),
2089 "later sibling's rect must not be available yet"
2090 );
2091 vec![Rect::new(ctx.container.x, ctx.container.y, 40.0, 20.0)]
2092 }),
2093 crate::widgets::button::button("after").key("later"),
2094 ]);
2095 let mut state = UiState::new();
2096 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2097 }
2098
2099 #[test]
2100 fn layout_override_measure_returns_intrinsic() {
2101 let mut root = column([crate::widgets::text::text("hi")
2103 .width(Size::Fixed(40.0))
2104 .height(Size::Fixed(20.0))])
2105 .width(Size::Fixed(200.0))
2106 .height(Size::Fixed(200.0))
2107 .layout(|ctx| {
2108 let (w, h) = (ctx.measure)(&ctx.children[0]);
2109 assert!((w - 40.0).abs() < 0.01, "measured width {w}");
2110 assert!((h - 20.0).abs() < 0.01, "measured height {h}");
2111 vec![Rect::new(ctx.container.x, ctx.container.y, w, h)]
2112 });
2113 let mut state = UiState::new();
2114 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2115 let r = state.rect(&root.children[0].computed_id);
2116 assert_eq!((r.w, r.h), (40.0, 20.0));
2117 }
2118
2119 #[test]
2120 #[should_panic(expected = "returned 1 rects for 2 children")]
2121 fn layout_override_length_mismatch_panics() {
2122 let mut root = column([
2123 crate::widgets::text::text("a")
2124 .width(Size::Fixed(10.0))
2125 .height(Size::Fixed(10.0)),
2126 crate::widgets::text::text("b")
2127 .width(Size::Fixed(10.0))
2128 .height(Size::Fixed(10.0)),
2129 ])
2130 .width(Size::Fixed(200.0))
2131 .height(Size::Fixed(200.0))
2132 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)]);
2133 let mut state = UiState::new();
2134 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2135 }
2136
2137 #[test]
2138 #[should_panic(expected = "Size::Hug is not supported for custom layouts")]
2139 fn layout_override_hug_panics() {
2140 let mut root = column([column([crate::widgets::text::text("c")])
2144 .width(Size::Hug)
2145 .height(Size::Fixed(200.0))
2146 .layout(|ctx| vec![Rect::new(ctx.container.x, ctx.container.y, 10.0, 10.0)])])
2147 .width(Size::Fixed(200.0))
2148 .height(Size::Fixed(200.0));
2149 let mut state = UiState::new();
2150 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
2151 }
2152
2153 #[test]
2154 fn virtual_list_realizes_only_visible_rows() {
2155 let mut root = crate::tree::virtual_list(100, 50.0, |i| {
2159 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2160 });
2161 let mut state = UiState::new();
2162 assign_ids(&mut root);
2163 state.scroll.offsets.insert(root.computed_id.clone(), 120.0);
2164 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2165
2166 assert_eq!(
2167 root.children.len(),
2168 5,
2169 "expected 5 realized rows, got {}",
2170 root.children.len()
2171 );
2172 assert_eq!(root.children[0].key.as_deref(), Some("row-2"));
2174 assert_eq!(root.children[4].key.as_deref(), Some("row-6"));
2175 let r0 = state.rect(&root.children[0].computed_id);
2177 assert!(
2178 (r0.y - (-20.0)).abs() < 0.5,
2179 "row 2 expected y≈-20, got {}",
2180 r0.y
2181 );
2182 }
2183
2184 #[test]
2185 fn virtual_list_keyed_rows_have_stable_computed_id_across_scroll() {
2186 let make_root = || {
2187 crate::tree::virtual_list(50, 50.0, |i| {
2188 crate::widgets::text::text(format!("row {i}")).key(format!("row-{i}"))
2189 })
2190 };
2191
2192 let mut state = UiState::new();
2193 let mut root_a = make_root();
2194 assign_ids(&mut root_a);
2195 state
2197 .scroll
2198 .offsets
2199 .insert(root_a.computed_id.clone(), 250.0);
2200 layout(&mut root_a, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2201 let id_at_offset_a = root_a
2202 .children
2203 .iter()
2204 .find(|c| c.key.as_deref() == Some("row-5"))
2205 .unwrap()
2206 .computed_id
2207 .clone();
2208
2209 let mut root_b = make_root();
2211 assign_ids(&mut root_b);
2212 state
2213 .scroll
2214 .offsets
2215 .insert(root_b.computed_id.clone(), 200.0);
2216 layout(&mut root_b, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2217 let id_at_offset_b = root_b
2218 .children
2219 .iter()
2220 .find(|c| c.key.as_deref() == Some("row-5"))
2221 .unwrap()
2222 .computed_id
2223 .clone();
2224
2225 assert_eq!(
2226 id_at_offset_a, id_at_offset_b,
2227 "row-5's computed_id changed when scroll offset moved"
2228 );
2229 }
2230
2231 #[test]
2232 fn virtual_list_clamps_overshoot_offset() {
2233 let mut root =
2235 crate::tree::virtual_list(10, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2236 let mut state = UiState::new();
2237 assign_ids(&mut root);
2238 state
2239 .scroll
2240 .offsets
2241 .insert(root.computed_id.clone(), 9999.0);
2242 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2243 let stored = state
2244 .scroll
2245 .offsets
2246 .get(&root.computed_id)
2247 .copied()
2248 .unwrap_or(0.0);
2249 assert!(
2250 (stored - 300.0).abs() < 0.01,
2251 "expected clamp to 300, got {stored}"
2252 );
2253 }
2254
2255 #[test]
2256 fn virtual_list_empty_count_realizes_no_children() {
2257 let mut root =
2258 crate::tree::virtual_list(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2259 let mut state = UiState::new();
2260 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2261 assert_eq!(root.children.len(), 0);
2262 }
2263
2264 #[test]
2265 #[should_panic(expected = "row_height > 0.0")]
2266 fn virtual_list_zero_row_height_panics() {
2267 let _ = crate::tree::virtual_list(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
2268 }
2269
2270 #[test]
2271 #[should_panic(expected = "Size::Hug would defeat virtualization")]
2272 fn virtual_list_hug_panics() {
2273 let mut root = column([crate::tree::virtual_list(10, 50.0, |i| {
2274 crate::widgets::text::text(format!("r{i}"))
2275 })
2276 .height(Size::Hug)])
2277 .width(Size::Fixed(300.0))
2278 .height(Size::Fixed(200.0));
2279 let mut state = UiState::new();
2280 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2281 }
2282
2283 #[test]
2284 fn virtual_list_dyn_respects_per_row_fixed_heights() {
2285 let mut root = crate::tree::virtual_list_dyn(20, 50.0, |i| {
2289 let h = if i % 2 == 0 { 40.0 } else { 80.0 };
2290 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2291 .key(format!("row-{i}"))
2292 .height(Size::Fixed(h))
2293 });
2294 let mut state = UiState::new();
2295 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2296
2297 assert_eq!(
2298 root.children.len(),
2299 4,
2300 "expected 4 realized rows, got {}",
2301 root.children.len()
2302 );
2303 let ys: Vec<f32> = root
2305 .children
2306 .iter()
2307 .map(|c| state.rect(&c.computed_id).y)
2308 .collect();
2309 assert!(
2310 (ys[0] - 0.0).abs() < 0.5,
2311 "row 0 expected y≈0, got {}",
2312 ys[0]
2313 );
2314 assert!(
2315 (ys[1] - 40.0).abs() < 0.5,
2316 "row 1 expected y≈40, got {}",
2317 ys[1]
2318 );
2319 assert!(
2320 (ys[2] - 120.0).abs() < 0.5,
2321 "row 2 expected y≈120, got {}",
2322 ys[2]
2323 );
2324 assert!(
2325 (ys[3] - 160.0).abs() < 0.5,
2326 "row 3 expected y≈160, got {}",
2327 ys[3]
2328 );
2329 }
2330
2331 #[test]
2332 fn virtual_list_dyn_caches_measured_heights() {
2333 let mut root = crate::tree::virtual_list_dyn(50, 50.0, |i| {
2337 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2338 .key(format!("row-{i}"))
2339 .height(Size::Fixed(30.0))
2340 });
2341 let mut state = UiState::new();
2342 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2343
2344 let measured = state
2345 .scroll
2346 .measured_row_heights
2347 .get(&root.computed_id)
2348 .expect("dynamic virtual list should populate the height cache");
2349 assert!(
2351 measured.len() >= 7,
2352 "expected ≥ 7 cached row heights, got {}",
2353 measured.len()
2354 );
2355 for (_, h) in measured.iter() {
2356 assert!(
2357 (h - 30.0).abs() < 0.5,
2358 "expected cached height ≈ 30, got {h}"
2359 );
2360 }
2361 }
2362
2363 #[test]
2364 fn virtual_list_dyn_total_height_uses_measured_plus_estimate() {
2365 let make_root = || {
2370 crate::tree::virtual_list_dyn(20, 50.0, |i| {
2371 crate::tree::column([crate::widgets::text::text(format!("r{i}"))])
2372 .key(format!("row-{i}"))
2373 .height(Size::Fixed(30.0))
2374 })
2375 };
2376 let mut state = UiState::new();
2377 let mut root = make_root();
2378 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2379
2380 let measured_count = state
2381 .scroll
2382 .measured_row_heights
2383 .get(&root.computed_id)
2384 .map(|m| m.len())
2385 .unwrap_or(0);
2386 let expected_total = measured_count as f32 * 30.0 + (20 - measured_count) as f32 * 50.0;
2387 let expected_max_offset = expected_total - 200.0;
2388
2389 state
2390 .scroll
2391 .offsets
2392 .insert(root.computed_id.clone(), 9999.0);
2393 let mut root2 = make_root();
2394 layout(&mut root2, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2395 let stored = state
2396 .scroll
2397 .offsets
2398 .get(&root2.computed_id)
2399 .copied()
2400 .unwrap_or(0.0);
2401 assert!(
2402 (stored - expected_max_offset).abs() < 0.5,
2403 "expected offset clamped to {expected_max_offset}, got {stored}"
2404 );
2405 }
2406
2407 #[test]
2408 fn virtual_list_dyn_empty_count_realizes_no_children() {
2409 let mut root =
2410 crate::tree::virtual_list_dyn(0, 50.0, |i| crate::widgets::text::text(format!("r{i}")));
2411 let mut state = UiState::new();
2412 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 200.0));
2413 assert_eq!(root.children.len(), 0);
2414 }
2415
2416 #[test]
2417 #[should_panic(expected = "estimated_row_height > 0.0")]
2418 fn virtual_list_dyn_zero_estimate_panics() {
2419 let _ =
2420 crate::tree::virtual_list_dyn(10, 0.0, |i| crate::widgets::text::text(format!("r{i}")));
2421 }
2422
2423 #[test]
2424 fn text_runs_constructor_shape_smoke() {
2425 let el = crate::tree::text_runs([
2426 crate::widgets::text::text("Hello, "),
2427 crate::widgets::text::text("world").bold(),
2428 crate::tree::hard_break(),
2429 crate::widgets::text::text("of text").italic(),
2430 ]);
2431 assert_eq!(el.kind, Kind::Inlines);
2432 assert_eq!(el.children.len(), 4);
2433 assert!(matches!(
2434 el.children[1].font_weight,
2435 FontWeight::Bold | FontWeight::Semibold
2436 ));
2437 assert_eq!(el.children[2].kind, Kind::HardBreak);
2438 assert!(el.children[3].text_italic);
2439 }
2440
2441 #[test]
2442 fn wrapped_text_hugs_multiline_height_from_available_width() {
2443 let mut root = column([crate::paragraph(
2444 "A longer sentence should wrap into multiple measured lines.",
2445 )])
2446 .width(Size::Fill(1.0))
2447 .height(Size::Hug);
2448
2449 let mut state = UiState::new();
2450 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 180.0, 200.0));
2451
2452 let child_rect = state.rect(&root.children[0].computed_id);
2453 assert_eq!(child_rect.w, 180.0);
2454 assert!(
2455 child_rect.h > crate::tokens::TEXT_SM.size * 1.4,
2456 "expected multiline paragraph height, got {}",
2457 child_rect.h
2458 );
2459 }
2460
2461 #[test]
2462 fn overlay_child_with_wrapped_text_measures_against_its_resolved_width() {
2463 const PANEL_W: f32 = 240.0;
2474 const PADDING: f32 = 18.0;
2475 const GAP: f32 = 12.0;
2476
2477 let panel = column([
2478 crate::paragraph(
2479 "A long enough warning paragraph that it has to wrap onto a second line \
2480 inside this narrow panel.",
2481 ),
2482 crate::widgets::button::button("OK").key("ok"),
2483 ])
2484 .width(Size::Fixed(PANEL_W))
2485 .height(Size::Hug)
2486 .padding(Sides::all(PADDING))
2487 .gap(GAP)
2488 .align(Align::Stretch);
2489
2490 let mut root = crate::stack([panel])
2491 .width(Size::Fill(1.0))
2492 .height(Size::Fill(1.0));
2493 let mut state = UiState::new();
2494 layout(&mut root, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
2495
2496 let panel_rect = state.rect(&root.children[0].computed_id);
2497 assert_eq!(panel_rect.w, PANEL_W, "panel keeps its Fixed width");
2498
2499 let para_rect = state.rect(&root.children[0].children[0].computed_id);
2500 let button_rect = state.rect(&root.children[0].children[1].computed_id);
2501
2502 assert!(
2505 para_rect.h > crate::tokens::TEXT_SM.size * 1.4,
2506 "paragraph should wrap to multiple lines inside the Fixed-width panel; \
2507 got h={}",
2508 para_rect.h
2509 );
2510
2511 let bottom_padding = (panel_rect.y + panel_rect.h) - (button_rect.y + button_rect.h);
2517 assert!(
2518 (bottom_padding - PADDING).abs() < 0.5,
2519 "expected {PADDING}px between button and panel bottom, got {bottom_padding}",
2520 );
2521 }
2522
2523 #[test]
2524 fn row_with_fill_paragraph_propagates_height_to_parent_column() {
2525 const COL_W: f32 = 600.0;
2537 const GUTTER_W: f32 = 3.0;
2538
2539 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, \
2540 sed do eiusmod tempor incididunt ut labore et dolore magna \
2541 aliqua. Ut enim ad minim veniam, quis nostrud exercitation \
2542 ullamco laboris nisi ut aliquip ex ea commodo consequat.";
2543
2544 let make_row = || {
2545 let gutter = El::new(Kind::Custom("gutter"))
2546 .width(Size::Fixed(GUTTER_W))
2547 .height(Size::Fill(1.0));
2548 let body = crate::paragraph(long).width(Size::Fill(1.0));
2549 crate::row([gutter, body]).width(Size::Fill(1.0))
2550 };
2551
2552 let mut root = column([make_row(), make_row()])
2553 .width(Size::Fixed(COL_W))
2554 .height(Size::Hug)
2555 .align(Align::Stretch);
2556 let mut state = UiState::new();
2557 layout(&mut root, &mut state, Rect::new(0.0, 0.0, COL_W, 2000.0));
2558
2559 let row0_rect = state.rect(&root.children[0].computed_id);
2560 let row1_rect = state.rect(&root.children[1].computed_id);
2561 let para0_rect = state.rect(&root.children[0].children[1].computed_id);
2562
2563 let line_height = crate::tokens::TEXT_SM.line_height;
2568 assert!(
2569 para0_rect.h > line_height * 1.5,
2570 "paragraph should wrap to multiple lines at ~597px wide; \
2571 got h={} (line_height={})",
2572 para0_rect.h,
2573 line_height,
2574 );
2575 assert!(
2576 row0_rect.h > line_height * 1.5,
2577 "row 0 should accommodate the wrapped paragraph height; \
2578 got h={} (line_height={})",
2579 row0_rect.h,
2580 line_height,
2581 );
2582
2583 assert!(
2585 row1_rect.y >= row0_rect.y + row0_rect.h - 0.5,
2586 "row 1 starts at y={} but row 0 occupies y={}..{}",
2587 row1_rect.y,
2588 row0_rect.y,
2589 row0_rect.y + row0_rect.h,
2590 );
2591 }
2592}