1pub use super::geometry::Rect;
2use std::collections::HashMap;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
7pub struct PaintId(pub u64);
8
9impl PaintId {
10 pub fn raw(self) -> u64 {
11 self.0
12 }
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Constraint {
31 Length(u16),
33 Percentage(u16),
35 Ratio(u16, u16),
38 Min(u16),
41 Max(u16),
44 Fill,
46 Fit,
49}
50
51pub type Item = (Constraint, LayoutTree);
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Justify {
58 #[default]
60 Start,
61 SpaceBetween,
64}
65
66#[derive(Clone, Debug, Default)]
69pub struct Chrome {
70 pub gap: u16,
72 pub justify: Justify,
74 pub border: Option<Border>,
76 pub title: Option<crate::line::Line<'static>>,
78 pub padding: u16,
82}
83
84pub trait Natural: Send + Sync {
91 fn size(&self, cap: (u16, u16)) -> (u16, u16);
95}
96
97pub type NaturalRef = std::sync::Arc<dyn Natural>;
99
100#[derive(Clone, Copy, Debug)]
103pub struct StaticNatural(pub u16, pub u16);
104
105impl Natural for StaticNatural {
106 fn size(&self, _cap: (u16, u16)) -> (u16, u16) {
107 (self.0, self.1)
108 }
109}
110
111#[derive(Clone)]
112pub enum LayoutTree {
113 Leaf {
118 id: PaintId,
119 chrome: Chrome,
120 natural: Option<NaturalRef>,
121 },
122 Vbox { items: Vec<Item>, chrome: Chrome },
124 Hbox { items: Vec<Item>, chrome: Chrome },
126}
127
128impl std::fmt::Debug for LayoutTree {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self {
131 LayoutTree::Leaf {
132 id,
133 chrome,
134 natural,
135 } => f
136 .debug_struct("Leaf")
137 .field("id", id)
138 .field("chrome", chrome)
139 .field("natural", &natural.as_ref().map(|_| "<NaturalRef>"))
140 .finish(),
141 LayoutTree::Vbox { items, chrome } => f
142 .debug_struct("Vbox")
143 .field("items", items)
144 .field("chrome", chrome)
145 .finish(),
146 LayoutTree::Hbox { items, chrome } => f
147 .debug_struct("Hbox")
148 .field("items", items)
149 .field("chrome", chrome)
150 .finish(),
151 }
152 }
153}
154
155impl LayoutTree {
156 pub fn vbox(items: Vec<Item>) -> Self {
158 Self::Vbox {
159 items,
160 chrome: Chrome::default(),
161 }
162 }
163
164 pub fn hbox(items: Vec<Item>) -> Self {
166 Self::Hbox {
167 items,
168 chrome: Chrome::default(),
169 }
170 }
171
172 pub fn leaf(id: impl Into<PaintId>) -> Self {
174 Self::Leaf {
175 id: id.into(),
176 chrome: Chrome::default(),
177 natural: None,
178 }
179 }
180
181 pub fn with_natural(mut self, n: NaturalRef) -> Self {
184 if let Self::Leaf { natural, .. } = &mut self {
185 *natural = Some(n);
186 }
187 self
188 }
189
190 pub fn chrome_mut(&mut self) -> &mut Chrome {
191 match self {
192 Self::Leaf { chrome, .. } | Self::Vbox { chrome, .. } | Self::Hbox { chrome, .. } => {
193 chrome
194 }
195 }
196 }
197
198 pub fn chrome(&self) -> &Chrome {
199 match self {
200 Self::Leaf { chrome, .. } | Self::Vbox { chrome, .. } | Self::Hbox { chrome, .. } => {
201 chrome
202 }
203 }
204 }
205
206 pub fn with_gap(mut self, g: u16) -> Self {
207 self.chrome_mut().gap = g;
208 self
209 }
210
211 pub fn with_justify(mut self, justify: Justify) -> Self {
212 self.chrome_mut().justify = justify;
213 self
214 }
215
216 pub fn with_padding(mut self, p: u16) -> Self {
217 self.chrome_mut().padding = p;
218 self
219 }
220
221 pub fn with_border(mut self, b: Border) -> Self {
222 self.chrome_mut().border = Some(b);
223 self
224 }
225
226 pub fn with_title(mut self, t: impl Into<crate::line::Line<'static>>) -> Self {
227 self.chrome_mut().title = Some(t.into());
228 self
229 }
230
231 pub fn set_title(&mut self, t: Option<crate::line::Line<'static>>) {
233 self.chrome_mut().title = t;
234 }
235
236 pub fn set_border(&mut self, b: Option<Border>) {
238 self.chrome_mut().border = b;
239 }
240
241 pub fn contains_leaf(&self, id: impl Into<PaintId>) -> bool {
243 let id = id.into();
244 self.contains_leaf_id(id)
245 }
246
247 fn contains_leaf_id(&self, id: PaintId) -> bool {
248 match self {
249 LayoutTree::Leaf { id: p, .. } => *p == id,
250 LayoutTree::Vbox { items, .. } | LayoutTree::Hbox { items, .. } => {
251 items.iter().any(|(_, child)| child.contains_leaf_id(id))
252 }
253 }
254 }
255
256 pub fn leaves_in_order(&self) -> Vec<PaintId> {
258 let mut out = Vec::new();
259 self.collect_leaves(&mut out);
260 out
261 }
262
263 fn collect_leaves(&self, out: &mut Vec<PaintId>) {
264 match self {
265 LayoutTree::Leaf { id, .. } => out.push(*id),
266 LayoutTree::Vbox { items, .. } | LayoutTree::Hbox { items, .. } => {
267 for (_, child) in items {
268 child.collect_leaves(out);
269 }
270 }
271 }
272 }
273
274 pub fn natural_size(&self, cap: (u16, u16)) -> (u16, u16) {
279 self.natural_size_with(cap, &NoopSizer)
280 }
281
282 pub fn natural_size_with(&self, cap: (u16, u16), sizer: &dyn LeafSizer) -> (u16, u16) {
287 match self {
288 LayoutTree::Leaf {
289 id,
290 chrome,
291 natural,
292 } => {
293 let (cw, ch) = chrome_overhead(chrome);
294 let inner_cap = (cap.0.saturating_sub(cw), cap.1.saturating_sub(ch));
295 let (w, h) = natural
296 .as_deref()
297 .map(|n| n.size(inner_cap))
298 .unwrap_or_else(|| sizer.leaf_natural_size(*id, inner_cap));
299 ((w + cw).min(cap.0), (h + ch).min(cap.1))
300 }
301 LayoutTree::Vbox { items, chrome } => natural_box(items, chrome, cap, true, sizer),
302 LayoutTree::Hbox { items, chrome } => natural_box(items, chrome, cap, false, sizer),
303 }
304 }
305}
306
307pub trait LeafSizer {
311 fn leaf_natural_size(&self, id: PaintId, cap: (u16, u16)) -> (u16, u16);
314}
315
316pub struct NoopSizer;
319
320impl LeafSizer for NoopSizer {
321 fn leaf_natural_size(&self, _id: PaintId, _cap: (u16, u16)) -> (u16, u16) {
322 (0, 0)
323 }
324}
325
326fn chrome_border_dims(chrome: &Chrome) -> (u16, u16) {
328 let Some(b) = chrome.border else {
329 return (0, 0);
330 };
331 let bw = u16::from(b.left.is_some()) + u16::from(b.right.is_some());
332 let bh = u16::from(b.top.is_some()) + u16::from(b.bottom.is_some());
333 (bw, bh)
334}
335
336fn chrome_overhead(chrome: &Chrome) -> (u16, u16) {
339 let (bw, bh) = chrome_border_dims(chrome);
340 let pad2 = chrome.padding.saturating_mul(2);
341 (bw.saturating_add(pad2), bh.saturating_add(pad2))
342}
343
344fn natural_box(
345 items: &[Item],
346 chrome: &Chrome,
347 cap: (u16, u16),
348 vertical: bool,
349 sizer: &dyn LeafSizer,
350) -> (u16, u16) {
351 let (cap_w, cap_h) = cap;
352 let (chrome_w, chrome_h) = chrome_overhead(chrome);
353 let gaps = chrome
354 .gap
355 .saturating_mul(items.len().saturating_sub(1) as u16);
356
357 let (primary_cap, secondary_cap) = if vertical {
359 (
360 cap_h.saturating_sub(chrome_h).saturating_sub(gaps),
361 cap_w.saturating_sub(chrome_w),
362 )
363 } else {
364 (
365 cap_w.saturating_sub(chrome_w).saturating_sub(gaps),
366 cap_h.saturating_sub(chrome_h),
367 )
368 };
369
370 let inner_cap = if vertical {
371 (secondary_cap, primary_cap)
372 } else {
373 (primary_cap, secondary_cap)
374 };
375
376 let mut primary = 0u16;
377 let mut secondary = 0u16;
378 for (constraint, child) in items {
379 let (child_w, child_h) = child.natural_size_with(inner_cap, sizer);
380 let primary_size = match constraint {
381 Constraint::Length(n) | Constraint::Max(n) | Constraint::Min(n) => *n,
382 Constraint::Percentage(p) => {
383 ((primary_cap as u32 * *p as u32) / 100).min(primary_cap as u32) as u16
384 }
385 Constraint::Ratio(num, denom) => {
386 if *denom == 0 {
387 0
388 } else {
389 ((primary_cap as u32 * *num as u32) / *denom as u32).min(primary_cap as u32)
390 as u16
391 }
392 }
393 Constraint::Fit => {
396 if vertical {
397 child_h
398 } else {
399 child_w
400 }
401 }
402 Constraint::Fill => 0,
403 };
404 let cross_size = if vertical { child_w } else { child_h };
405 primary = primary.saturating_add(primary_size);
406 secondary = secondary.max(cross_size);
407 }
408 let (primary_chrome, secondary_chrome) = if vertical {
409 (chrome_h, chrome_w)
410 } else {
411 (chrome_w, chrome_h)
412 };
413 primary = primary.saturating_add(gaps).saturating_add(primary_chrome);
414 secondary = secondary.saturating_add(secondary_chrome);
415
416 let (w, h) = if vertical {
417 (secondary, primary)
418 } else {
419 (primary, secondary)
420 };
421 (w.min(cap_w), h.min(cap_h))
422}
423
424#[derive(Clone, Copy, Debug, PartialEq, Eq)]
428pub enum Corner {
429 NW,
430 NE,
431 SW,
432 SE,
433}
434
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum Align {
442 NW,
443 N,
444 NE,
445 W,
446 Center,
447 E,
448 SW,
449 S,
450 SE,
451}
452
453impl From<Corner> for Align {
454 fn from(c: Corner) -> Self {
455 match c {
456 Corner::NW => Align::NW,
457 Corner::NE => Align::NE,
458 Corner::SW => Align::SW,
459 Corner::SE => Align::SE,
460 }
461 }
462}
463
464#[derive(Clone, Debug, PartialEq, Eq)]
467pub enum Anchor {
468 ScreenCenter,
470 ScreenAt { row: i32, col: i32, corner: Corner },
472 Cursor {
474 corner: Corner,
475 row_offset: i32,
476 col_offset: i32,
477 },
478 Win {
486 target: PaintId,
487 attach: Align,
488 row_offset: i32,
489 col_offset: i32,
490 },
491 ScreenBottom { above_rows: u16 },
493}
494
495#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
497pub enum BorderStyle {
498 #[default]
499 Single,
500 Double,
501 Rounded,
502 Dashed,
504}
505
506#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
510pub struct EdgeStyle {
511 pub color: Option<smelt_style::theme::HlGroup>,
512}
513
514impl EdgeStyle {
515 pub const fn new() -> Self {
516 Self { color: None }
517 }
518 pub const fn with_color(hl: smelt_style::theme::HlGroup) -> Self {
519 Self { color: Some(hl) }
520 }
521}
522
523impl From<()> for EdgeStyle {
524 fn from(_: ()) -> Self {
525 Self::new()
526 }
527}
528
529impl From<smelt_style::theme::HlGroup> for EdgeStyle {
530 fn from(hl: smelt_style::theme::HlGroup) -> Self {
531 Self::with_color(hl)
532 }
533}
534
535#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
539pub struct Border {
540 pub style: BorderStyle,
541 pub top: Option<EdgeStyle>,
542 pub right: Option<EdgeStyle>,
543 pub bottom: Option<EdgeStyle>,
544 pub left: Option<EdgeStyle>,
545}
546
547impl Border {
548 pub const OFF: Self = Self {
550 style: BorderStyle::Single,
551 top: None,
552 right: None,
553 bottom: None,
554 left: None,
555 };
556
557 pub const fn single() -> Self {
558 Self {
559 style: BorderStyle::Single,
560 ..Self::OFF
561 }
562 }
563 pub const fn rounded() -> Self {
564 Self {
565 style: BorderStyle::Rounded,
566 ..Self::OFF
567 }
568 }
569 pub const fn double() -> Self {
570 Self {
571 style: BorderStyle::Double,
572 ..Self::OFF
573 }
574 }
575
576 pub fn top(mut self, e: impl Into<EdgeStyle>) -> Self {
577 self.top = Some(e.into());
578 self
579 }
580 pub fn right(mut self, e: impl Into<EdgeStyle>) -> Self {
581 self.right = Some(e.into());
582 self
583 }
584 pub fn bottom(mut self, e: impl Into<EdgeStyle>) -> Self {
585 self.bottom = Some(e.into());
586 self
587 }
588 pub fn left(mut self, e: impl Into<EdgeStyle>) -> Self {
589 self.left = Some(e.into());
590 self
591 }
592 pub fn all<E: Into<EdgeStyle> + Copy>(self, e: E) -> Self {
594 self.top(e).right(e).bottom(e).left(e)
595 }
596
597 pub fn any_side(&self) -> bool {
598 self.top.is_some() || self.right.is_some() || self.bottom.is_some() || self.left.is_some()
599 }
600
601 pub fn single_all() -> Self {
603 Self::single().all(())
604 }
605 pub fn rounded_all() -> Self {
606 Self::rounded().all(())
607 }
608 pub fn double_all() -> Self {
609 Self::double().all(())
610 }
611
612 pub const SINGLE: Border = Border {
614 style: BorderStyle::Single,
615 top: Some(EdgeStyle::new()),
616 right: Some(EdgeStyle::new()),
617 bottom: Some(EdgeStyle::new()),
618 left: Some(EdgeStyle::new()),
619 };
620 pub const DOUBLE: Border = Border {
621 style: BorderStyle::Double,
622 top: Some(EdgeStyle::new()),
623 right: Some(EdgeStyle::new()),
624 bottom: Some(EdgeStyle::new()),
625 left: Some(EdgeStyle::new()),
626 };
627 pub const ROUNDED: Border = Border {
628 style: BorderStyle::Rounded,
629 top: Some(EdgeStyle::new()),
630 right: Some(EdgeStyle::new()),
631 bottom: Some(EdgeStyle::new()),
632 left: Some(EdgeStyle::new()),
633 };
634}
635
636#[derive(Clone, Copy, Debug)]
637pub struct Gutters {
638 pub pad_left: u16,
639 pub pad_right: u16,
640 pub scrollbar: bool,
641}
642
643impl Default for Gutters {
644 fn default() -> Self {
650 Self {
651 pad_left: 0,
652 pad_right: 0,
653 scrollbar: true,
654 }
655 }
656}
657
658impl Gutters {
659 pub fn scrollbar_width(&self) -> u16 {
660 if self.scrollbar {
661 1
662 } else {
663 0
664 }
665 }
666
667 pub fn layer_width(&self, total: u16) -> u16 {
669 total.saturating_sub(self.pad_left)
670 }
671
672 pub fn content_width(&self, total: u16) -> u16 {
674 self.layer_width(total)
675 .saturating_sub(self.pad_right)
676 .saturating_sub(self.scrollbar_width())
677 }
678}
679
680#[derive(Clone, Copy, Debug, PartialEq, Eq)]
681pub struct LayoutRect {
682 pub id: PaintId,
683 pub rect: Rect,
684}
685
686pub fn resolve_layout(tree: &LayoutTree, area: Rect) -> HashMap<PaintId, Rect> {
690 resolve_layout_with(tree, area, &NoopSizer)
691}
692
693pub fn resolve_layout_with(
697 tree: &LayoutTree,
698 area: Rect,
699 sizer: &dyn LeafSizer,
700) -> HashMap<PaintId, Rect> {
701 resolve_layout_ordered_with(tree, area, sizer)
702 .into_iter()
703 .map(|r| (r.id, r.rect))
704 .collect()
705}
706
707pub fn resolve_layout_ordered(tree: &LayoutTree, area: Rect) -> Vec<LayoutRect> {
710 resolve_layout_ordered_with(tree, area, &NoopSizer)
711}
712
713pub fn resolve_layout_ordered_with(
716 tree: &LayoutTree,
717 area: Rect,
718 sizer: &dyn LeafSizer,
719) -> Vec<LayoutRect> {
720 let mut result = Vec::new();
721 resolve_node_ordered(tree, area, sizer, &mut result);
722 result
723}
724
725pub fn inset_for_border(area: Rect, border: Option<Border>) -> Rect {
730 let Some(b) = border else {
731 return area;
732 };
733 let top_pad = if b.top.is_some() { 1 } else { 0 };
734 let bot_pad = if b.bottom.is_some() { 1 } else { 0 };
735 let left_pad = if b.left.is_some() { 1 } else { 0 };
736 let right_pad = if b.right.is_some() { 1 } else { 0 };
737 let h = area.height.saturating_sub(top_pad).saturating_sub(bot_pad);
738 let w = area
739 .width
740 .saturating_sub(left_pad)
741 .saturating_sub(right_pad);
742 Rect::new(area.top + top_pad, area.left + left_pad, w, h)
743}
744
745pub fn inset_for_chrome(area: Rect, chrome: &Chrome) -> Rect {
749 let bordered = inset_for_border(area, chrome.border);
750 let p = chrome.padding;
751 if p == 0 {
752 return bordered;
753 }
754 let top = bordered.top + p;
755 let left = bordered.left + p;
756 let w = bordered.width.saturating_sub(p).saturating_sub(p);
757 let h = bordered.height.saturating_sub(p).saturating_sub(p);
758 Rect::new(top, left, w, h)
759}
760
761pub fn paint_chrome(
766 grid: &mut crate::grid::Grid,
767 area: Rect,
768 chrome: &Chrome,
769 theme: &crate::Theme,
770) {
771 let Some(border) = chrome.border else {
772 return;
773 };
774 if !border.any_side() {
775 return;
776 }
777 if area.width == 0 || area.height == 0 {
778 return;
779 }
780 let (h, v, tl, tr, bl, br) = match border.style {
781 BorderStyle::Single => ('─', '│', '┌', '┐', '└', '┘'),
782 BorderStyle::Double => ('═', '║', '╔', '╗', '╚', '╝'),
783 BorderStyle::Rounded => ('─', '│', '╭', '╮', '╰', '╯'),
784 BorderStyle::Dashed => ('╌', '╎', '┌', '┐', '└', '┘'),
785 };
786 let edge_style = |e: Option<EdgeStyle>| -> super::grid::Style {
787 match e.and_then(|s| s.color) {
788 Some(hl) => {
789 let mut style = theme.resolve(hl);
790 style.bg = None;
791 style
792 }
793 None => super::grid::Style::default(),
794 }
795 };
796 let top_style = edge_style(border.top);
797 let bot_style = edge_style(border.bottom);
798 let left_style = edge_style(border.left);
799 let right_style = edge_style(border.right);
800 let right = area.left + area.width - 1;
801 let bottom = area.top + area.height - 1;
802
803 if border.top.is_some() {
804 for col in area.left..=right {
805 grid.set(col, area.top, h, top_style);
806 }
807 }
808 if border.bottom.is_some() && bottom != area.top {
809 for col in area.left..=right {
810 grid.set(col, bottom, h, bot_style);
811 }
812 }
813 if border.left.is_some() {
814 for row in area.top..=bottom {
815 grid.set(area.left, row, v, left_style);
816 }
817 }
818 if border.right.is_some() && right != area.left {
819 for row in area.top..=bottom {
820 grid.set(right, row, v, right_style);
821 }
822 }
823 if border.top.is_some() && border.left.is_some() {
825 grid.set(area.left, area.top, tl, top_style);
826 }
827 if border.top.is_some() && border.right.is_some() && right != area.left {
828 grid.set(right, area.top, tr, top_style);
829 }
830 if border.bottom.is_some() && border.left.is_some() && bottom != area.top {
831 grid.set(area.left, bottom, bl, bot_style);
832 }
833 if border.bottom.is_some() && border.right.is_some() && bottom != area.top && right != area.left
834 {
835 grid.set(right, bottom, br, bot_style);
836 }
837
838 if border.top.is_some() {
839 if let Some(title) = chrome.title.as_ref() {
840 let title_left = area.left + 1;
843 let title_right_excl = right;
844 if title_right_excl > title_left {
845 let limit = title_right_excl;
846 let mut col = title_left;
847 for span in &title.spans {
848 if col >= limit {
849 break;
850 }
851 let span_style = merge_title_span_style(top_style, span.style);
852 let mut written = false;
853 for ch in span.text.chars() {
854 let cw = crate::grid::char_width(ch);
855 if col + cw > limit {
856 break;
857 }
858 grid.set(col, area.top, ch, span_style);
859 col += cw;
860 written = true;
861 }
862 if !written {
863 break;
864 }
865 }
866 }
867 }
868 }
869}
870
871fn merge_title_span_style(
873 base: crate::grid::Style,
874 span: crate::grid::Style,
875) -> crate::grid::Style {
876 crate::grid::Style {
877 fg: span.fg.or(base.fg),
878 bg: span.bg.or(base.bg),
879 bold: base.bold || span.bold,
880 dim: base.dim || span.dim,
881 italic: base.italic || span.italic,
882 underline: base.underline || span.underline,
883 crossedout: base.crossedout || span.crossedout,
884 reverse: base.reverse || span.reverse,
885 }
886}
887
888fn resolve_node_ordered(
889 node: &LayoutTree,
890 area: Rect,
891 sizer: &dyn LeafSizer,
892 out: &mut Vec<LayoutRect>,
893) {
894 match node {
895 LayoutTree::Leaf { id, chrome, .. } => {
896 out.push(LayoutRect {
897 id: *id,
898 rect: inset_for_chrome(area, chrome),
899 });
900 }
901 LayoutTree::Vbox { items, chrome } => {
902 resolve_box_ordered(items, chrome, area, true, sizer, out);
903 }
904 LayoutTree::Hbox { items, chrome } => {
905 resolve_box_ordered(items, chrome, area, false, sizer, out);
906 }
907 }
908}
909
910pub fn layout_box_children(
914 items: &[Item],
915 chrome: &Chrome,
916 area: Rect,
917 vertical: bool,
918 sizer: &dyn LeafSizer,
919) -> (Rect, Vec<Rect>) {
920 let inner = inset_for_chrome(area, chrome);
921 let total_gap = chrome
922 .gap
923 .saturating_mul(items.len().saturating_sub(1) as u16);
924 let primary_total = if vertical { inner.height } else { inner.width };
925 let available = primary_total.saturating_sub(total_gap);
926
927 let fit_caps: Vec<Option<u16>> = items
929 .iter()
930 .map(|(c, child)| match c {
931 Constraint::Fit => {
932 let leaf_cap = if vertical {
933 (inner.width, available)
934 } else {
935 (available, inner.height)
936 };
937 let (nw, nh) = child.natural_size_with(leaf_cap, sizer);
938 Some(if vertical { nh } else { nw })
939 }
940 _ => None,
941 })
942 .collect();
943
944 let sizes = resolve_constraints_with_fit_caps(items, available, &fit_caps);
945 let used_sizes = sizes
946 .iter()
947 .fold(0u16, |acc, size| acc.saturating_add(*size));
948 let used = used_sizes.saturating_add(total_gap);
949 let extra = primary_total.saturating_sub(used);
950 let gap_count = items.len().saturating_sub(1) as u16;
951 let (extra_per_gap, mut extra_remainder) =
952 if chrome.justify == Justify::SpaceBetween && gap_count > 0 {
953 (extra / gap_count, extra % gap_count)
954 } else {
955 (0, 0)
956 };
957
958 let mut rects = Vec::with_capacity(items.len());
959 let mut offset = 0u16;
960 for (i, &size) in sizes.iter().enumerate() {
961 let r = if vertical {
962 Rect::new(inner.top + offset, inner.left, inner.width, size)
963 } else {
964 Rect::new(inner.top, inner.left + offset, size, inner.height)
965 };
966 rects.push(r);
967 offset = offset.saturating_add(size);
968 if i + 1 < items.len() {
969 offset = offset
970 .saturating_add(chrome.gap)
971 .saturating_add(extra_per_gap);
972 if extra_remainder > 0 {
973 offset = offset.saturating_add(1);
974 extra_remainder -= 1;
975 }
976 }
977 }
978 (inner, rects)
979}
980
981fn resolve_box_ordered(
982 items: &[Item],
983 chrome: &Chrome,
984 area: Rect,
985 vertical: bool,
986 sizer: &dyn LeafSizer,
987 out: &mut Vec<LayoutRect>,
988) {
989 let (_, rects) = layout_box_children(items, chrome, area, vertical, sizer);
990 for ((_, child), &rect) in items.iter().zip(rects.iter()) {
991 resolve_node_ordered(child, rect, sizer, out);
992 }
993}
994
995pub fn resolve_constraints(items: &[Item], total: u16) -> Vec<u16> {
996 let caps: Vec<Option<u16>> = vec![None; items.len()];
997 resolve_constraints_with_fit_caps(items, total, &caps)
998}
999
1000pub fn resolve_constraints_with_fit_caps(
1007 items: &[Item],
1008 total: u16,
1009 fit_caps: &[Option<u16>],
1010) -> Vec<u16> {
1011 let mut sizes = vec![0u16; items.len()];
1012 let mut remaining = total;
1013
1014 for (i, (c, _)) in items.iter().enumerate() {
1016 match c {
1017 Constraint::Length(n) => {
1018 let n = (*n).min(remaining);
1019 sizes[i] = n;
1020 remaining -= n;
1021 }
1022 Constraint::Percentage(pct) => {
1023 let n = ((total as u32 * *pct as u32) / 100).min(remaining as u32) as u16;
1024 sizes[i] = n;
1025 remaining -= n;
1026 }
1027 _ => {}
1028 }
1029 }
1030
1031 let ratio_total: u32 = items
1033 .iter()
1034 .filter_map(|(c, _)| match c {
1035 Constraint::Ratio(num, _) => Some(*num as u32),
1036 _ => None,
1037 })
1038 .sum();
1039 let ratio_pool = remaining;
1040 let mut consumed = 0u16;
1041 for (i, (c, _)) in items.iter().enumerate() {
1042 if let Constraint::Ratio(num, _) = c {
1043 let n = (ratio_pool as u32 * *num as u32)
1044 .checked_div(ratio_total)
1045 .unwrap_or(0) as u16;
1046 sizes[i] = n;
1047 consumed += n;
1048 }
1049 }
1050 remaining -= consumed.min(remaining);
1051
1052 let elastic: Vec<(usize, u16, u16)> = items
1062 .iter()
1063 .enumerate()
1064 .filter_map(|(i, (c, _))| {
1065 elastic_bounds(*c, fit_caps.get(i).copied().flatten()).map(|(f, cap)| (i, f, cap))
1066 })
1067 .collect();
1068 if elastic.is_empty() || remaining == 0 {
1069 return sizes;
1073 }
1074
1075 let mut shares = vec![0u16; elastic.len()];
1082 let caps: Vec<u32> = elastic.iter().map(|&(_, _, c)| c as u32).collect();
1083 let mut to_allocate = remaining as u32;
1084 loop {
1085 let uncapped: Vec<usize> = (0..elastic.len())
1086 .filter(|&k| (shares[k] as u32) < caps[k])
1087 .collect();
1088 if uncapped.is_empty() || to_allocate == 0 {
1089 break;
1090 }
1091 let m = uncapped.len() as u32;
1092 let per = to_allocate / m;
1093 let mut leftover = to_allocate % m;
1094 let mut allocated: u32 = 0;
1095 for &k in &uncapped {
1096 let want = per + u32::from(leftover > 0);
1097 leftover = leftover.saturating_sub(1);
1098 let room = caps[k] - shares[k] as u32;
1099 let take = want.min(room);
1100 shares[k] = shares[k].saturating_add(take as u16);
1101 allocated += take;
1102 }
1103 if allocated == 0 {
1104 break; }
1106 to_allocate = to_allocate.saturating_sub(allocated);
1107 }
1108
1109 for (k, &(_, floor, _)) in elastic.iter().enumerate() {
1112 if shares[k] < floor {
1113 shares[k] = floor;
1114 }
1115 }
1116 let total_shares: u32 = shares.iter().map(|&v| v as u32).sum();
1117 if total_shares > remaining as u32 {
1118 let mut over = (total_shares - remaining as u32) as u16;
1119 for (k, &(_, floor, _)) in elastic.iter().enumerate() {
1120 if over == 0 {
1121 break;
1122 }
1123 if floor == 0 {
1124 let take = shares[k].min(over);
1125 shares[k] -= take;
1126 over -= take;
1127 }
1128 }
1129 if over > 0 {
1130 let floored_total: u32 = elastic
1131 .iter()
1132 .enumerate()
1133 .filter(|(_, &(_, f, _))| f > 0)
1134 .map(|(k, _)| shares[k] as u32)
1135 .sum();
1136 if let Some(divisor) = std::num::NonZeroU32::new(floored_total) {
1137 for (k, &(_, f, _)) in elastic.iter().enumerate() {
1138 if f > 0 {
1139 let take = ((shares[k] as u32 * over as u32) / divisor) as u16;
1140 shares[k] = shares[k].saturating_sub(take);
1141 }
1142 }
1143 let new_total: u32 = shares.iter().map(|&v| v as u32).sum();
1145 let mut residual = new_total.saturating_sub(remaining as u32) as u16;
1146 for (k, &(_, f, _)) in elastic.iter().enumerate() {
1147 if residual == 0 {
1148 break;
1149 }
1150 if f > 0 {
1151 let take = shares[k].min(residual);
1152 shares[k] -= take;
1153 residual -= take;
1154 }
1155 }
1156 }
1157 }
1158 }
1159
1160 for (k, &(i, _, _)) in elastic.iter().enumerate() {
1161 sizes[i] = shares[k];
1162 }
1163 sizes
1164}
1165
1166fn elastic_bounds(c: Constraint, fit_cap: Option<u16>) -> Option<(u16, u16)> {
1169 match c {
1170 Constraint::Fill => Some((0, u16::MAX)),
1171 Constraint::Fit => Some((0, fit_cap.unwrap_or(u16::MAX))),
1172 Constraint::Min(n) => Some((n, u16::MAX)),
1173 Constraint::Max(n) => Some((0, n)),
1174 Constraint::Length(_) | Constraint::Percentage(_) | Constraint::Ratio(_, _) => None,
1175 }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181
1182 const A: PaintId = PaintId(100);
1183 const B: PaintId = PaintId(101);
1184 const C: PaintId = PaintId(102);
1185
1186 #[test]
1187 fn single_leaf_fills_area() {
1188 let tree = LayoutTree::leaf(A);
1189 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1190 assert_eq!(result[&A], Rect::new(0, 0, 80, 24));
1191 }
1192
1193 #[test]
1194 fn vertical_split_fixed_and_fill() {
1195 let tree = LayoutTree::vbox(vec![
1196 (Constraint::Fill, LayoutTree::leaf(A)),
1197 (Constraint::Length(5), LayoutTree::leaf(B)),
1198 ]);
1199 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1200 assert_eq!(result[&A], Rect::new(0, 0, 80, 19));
1201 assert_eq!(result[&B], Rect::new(19, 0, 80, 5));
1202 }
1203
1204 #[test]
1205 fn vertical_space_between_puts_surplus_into_gap() {
1206 let tree = LayoutTree::vbox(vec![
1207 (Constraint::Length(2), LayoutTree::leaf(A)),
1208 (Constraint::Length(3), LayoutTree::leaf(B)),
1209 ])
1210 .with_gap(1)
1211 .with_justify(Justify::SpaceBetween);
1212 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 10));
1213 assert_eq!(result[&A], Rect::new(0, 0, 80, 2));
1214 assert_eq!(result[&B], Rect::new(7, 0, 80, 3));
1215 }
1216
1217 #[test]
1218 fn vertical_split_pct_and_fill() {
1219 let tree = LayoutTree::vbox(vec![
1220 (Constraint::Fill, LayoutTree::leaf(A)),
1221 (Constraint::Percentage(25), LayoutTree::leaf(B)),
1222 ]);
1223 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1224 assert_eq!(result[&B].height, 6);
1225 assert_eq!(result[&A].height, 18);
1226 }
1227
1228 #[test]
1229 fn horizontal_split() {
1230 let tree = LayoutTree::hbox(vec![
1231 (Constraint::Length(20), LayoutTree::leaf(A)),
1232 (Constraint::Fill, LayoutTree::leaf(B)),
1233 ]);
1234 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1235 assert_eq!(result[&A], Rect::new(0, 0, 20, 24));
1236 assert_eq!(result[&B], Rect::new(0, 20, 60, 24));
1237 }
1238
1239 #[test]
1240 fn multiple_fills_distribute_evenly() {
1241 let tree = LayoutTree::vbox(vec![
1242 (Constraint::Fill, LayoutTree::leaf(A)),
1243 (Constraint::Fill, LayoutTree::leaf(B)),
1244 ]);
1245 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1246 assert_eq!(result[&A].height, 12);
1247 assert_eq!(result[&B].height, 12);
1248 }
1249
1250 #[test]
1251 fn rect_contains() {
1252 let r = Rect::new(5, 10, 20, 10);
1253 assert!(r.contains(5, 10));
1254 assert!(r.contains(14, 29));
1255 assert!(!r.contains(15, 10));
1256 assert!(!r.contains(5, 30));
1257 }
1258
1259 #[test]
1260 fn nested_split() {
1261 let tree = LayoutTree::vbox(vec![
1262 (
1263 Constraint::Fill,
1264 LayoutTree::hbox(vec![
1265 (Constraint::Fill, LayoutTree::leaf(A)),
1266 (Constraint::Fill, LayoutTree::leaf(B)),
1267 ]),
1268 ),
1269 (Constraint::Length(4), LayoutTree::leaf(C)),
1270 ]);
1271 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1272 assert_eq!(result[&C], Rect::new(20, 0, 80, 4));
1273 assert_eq!(result[&A], Rect::new(0, 0, 40, 20));
1274 assert_eq!(result[&B], Rect::new(0, 40, 40, 20));
1275 }
1276
1277 #[test]
1278 fn ordered_resolution_preserves_repeated_ids() {
1279 let tree = LayoutTree::vbox(vec![
1280 (Constraint::Length(1), LayoutTree::leaf(A)),
1281 (Constraint::Length(1), LayoutTree::leaf(A)),
1282 ]);
1283 let result = resolve_layout_ordered(&tree, Rect::new(0, 0, 10, 2));
1284 assert_eq!(
1285 result,
1286 vec![
1287 LayoutRect {
1288 id: A,
1289 rect: Rect::new(0, 0, 10, 1),
1290 },
1291 LayoutRect {
1292 id: A,
1293 rect: Rect::new(1, 0, 10, 1),
1294 },
1295 ]
1296 );
1297 }
1298
1299 #[test]
1300 fn map_resolution_keeps_last_repeated_id() {
1301 let tree = LayoutTree::vbox(vec![
1302 (Constraint::Length(1), LayoutTree::leaf(A)),
1303 (Constraint::Length(1), LayoutTree::leaf(A)),
1304 ]);
1305 let result = resolve_layout(&tree, Rect::new(0, 0, 10, 2));
1306 assert_eq!(result[&A], Rect::new(1, 0, 10, 1));
1307 }
1308
1309 #[test]
1310 fn min_competes_with_fill_for_equal_share_when_floor_satisfied() {
1311 let tree = LayoutTree::vbox(vec![
1312 (Constraint::Min(3), LayoutTree::leaf(A)),
1313 (Constraint::Fill, LayoutTree::leaf(B)),
1314 ]);
1315 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1316 assert_eq!(result[&A].height, 12);
1317 assert_eq!(result[&B].height, 12);
1318 }
1319
1320 #[test]
1321 fn min_clamps_up_to_floor_when_equal_share_too_small() {
1322 let tree = LayoutTree::vbox(vec![
1323 (Constraint::Min(20), LayoutTree::leaf(A)),
1324 (Constraint::Fill, LayoutTree::leaf(B)),
1325 ]);
1326 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1327 assert_eq!(result[&A].height, 20);
1328 assert_eq!(result[&B].height, 4);
1329 }
1330
1331 #[test]
1332 fn min_zero_alone_consumes_all_remaining() {
1333 let tree = LayoutTree::vbox(vec![(Constraint::Min(0), LayoutTree::leaf(A))]);
1334 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1335 assert_eq!(result[&A].height, 24);
1336 }
1337
1338 #[test]
1339 fn min_with_length_sibling_takes_remainder() {
1340 let tree = LayoutTree::vbox(vec![
1341 (Constraint::Length(10), LayoutTree::leaf(A)),
1342 (Constraint::Min(0), LayoutTree::leaf(B)),
1343 ]);
1344 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1345 assert_eq!(result[&A].height, 10);
1346 assert_eq!(result[&B].height, 14);
1347 }
1348
1349 #[test]
1350 fn two_mins_split_evenly_when_total_overruns_floors() {
1351 let tree = LayoutTree::vbox(vec![
1352 (Constraint::Min(20), LayoutTree::leaf(A)),
1353 (Constraint::Min(20), LayoutTree::leaf(B)),
1354 ]);
1355 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1356 assert_eq!(result[&A].height + result[&B].height, 24);
1357 assert!((result[&A].height as i32 - result[&B].height as i32).abs() <= 1);
1358 }
1359
1360 #[test]
1361 fn max_caps_at_ceiling_when_parent_has_room() {
1362 let tree = LayoutTree::vbox(vec![
1363 (Constraint::Max(5), LayoutTree::leaf(A)),
1364 (Constraint::Fill, LayoutTree::leaf(B)),
1365 ]);
1366 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1367 assert_eq!(result[&A].height, 5);
1368 assert_eq!(result[&B].height, 19);
1369 }
1370
1371 #[test]
1372 fn max_shrinks_when_parent_smaller_than_ceiling() {
1373 let tree = LayoutTree::vbox(vec![(Constraint::Max(50), LayoutTree::leaf(A))]);
1374 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1375 assert_eq!(result[&A].height, 24);
1376 }
1377
1378 #[test]
1379 fn ratio_splits_remaining_proportionally() {
1380 let tree = LayoutTree::hbox(vec![
1381 (Constraint::Ratio(1, 3), LayoutTree::leaf(A)),
1382 (Constraint::Ratio(2, 3), LayoutTree::leaf(B)),
1383 ]);
1384 let result = resolve_layout(&tree, Rect::new(0, 0, 90, 24));
1385 assert_eq!(result[&A].width, 30);
1386 assert_eq!(result[&B].width, 60);
1387 }
1388
1389 #[test]
1390 fn ratio_competes_with_length_for_remaining() {
1391 let tree = LayoutTree::hbox(vec![
1392 (Constraint::Length(20), LayoutTree::leaf(A)),
1393 (Constraint::Ratio(1, 2), LayoutTree::leaf(B)),
1394 (Constraint::Ratio(1, 2), LayoutTree::leaf(C)),
1395 ]);
1396 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1397 assert_eq!(result[&A].width, 20);
1398 assert_eq!(result[&B].width, 30);
1399 assert_eq!(result[&C].width, 30);
1400 }
1401
1402 #[test]
1403 fn fit_with_noop_sizer_contributes_zero() {
1404 let tree = LayoutTree::vbox(vec![
1405 (Constraint::Fit, LayoutTree::leaf(A)),
1406 (Constraint::Fill, LayoutTree::leaf(B)),
1407 ]);
1408 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1409 assert_eq!(result[&A].height, 0);
1411 assert_eq!(result[&B].height, 24);
1412 }
1413
1414 struct FixedSizer(u16);
1415
1416 impl LeafSizer for FixedSizer {
1417 fn leaf_natural_size(&self, _id: PaintId, _cap: (u16, u16)) -> (u16, u16) {
1418 (0, self.0)
1419 }
1420 }
1421
1422 struct PerLeafSizer(std::collections::HashMap<PaintId, u16>);
1424
1425 impl LeafSizer for PerLeafSizer {
1426 fn leaf_natural_size(&self, id: PaintId, cap: (u16, u16)) -> (u16, u16) {
1427 (0, self.0.get(&id).copied().unwrap_or(0).min(cap.1))
1428 }
1429 }
1430
1431 #[test]
1436 fn confirm_dialog_layout_consumes_all_rows_at_varying_heights() {
1437 let header = PaintId(101);
1438 let preview = PaintId(102);
1439 let allow = PaintId(103);
1440 let options = PaintId(104);
1441 let spacer = PaintId(105);
1442 let reason = PaintId(106);
1443
1444 let mut naturals = std::collections::HashMap::new();
1445 naturals.insert(header, 1);
1446 naturals.insert(preview, 50); naturals.insert(allow, 1);
1448 naturals.insert(options, 4);
1449 naturals.insert(spacer, 1);
1450 naturals.insert(reason, 1);
1451 let sizer = PerLeafSizer(naturals);
1452
1453 let tree = LayoutTree::vbox(vec![
1454 (Constraint::Fit, LayoutTree::leaf(header)),
1455 (Constraint::Fit, LayoutTree::leaf(preview)),
1456 (Constraint::Fit, LayoutTree::leaf(allow)),
1457 (Constraint::Fit, LayoutTree::leaf(options)),
1458 (Constraint::Fit, LayoutTree::leaf(spacer)),
1459 (Constraint::Fit, LayoutTree::leaf(reason)),
1460 ]);
1461
1462 for h in [8u16, 10, 12, 15, 18, 20, 24, 30, 40] {
1463 let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, h), &sizer);
1464 let used: u16 = result.values().map(|r| r.height).sum();
1465 assert_eq!(
1466 used,
1467 h,
1468 "h={h}: panels used {used} rows, leaving {} unused",
1469 h - used
1470 );
1471 assert_eq!(result[&header].height, 1, "h={h}: header");
1473 assert_eq!(result[&allow].height, 1, "h={h}: allow");
1474 assert_eq!(result[&spacer].height, 1, "h={h}: spacer");
1475 assert_eq!(result[&reason].height, 1, "h={h}: reason");
1476 }
1477 }
1478
1479 #[test]
1483 fn confirm_dialog_no_preview_packs_tight_at_varying_heights() {
1484 let header = PaintId(101);
1485 let preview = PaintId(102);
1486 let allow = PaintId(103);
1487 let options = PaintId(104);
1488 let spacer = PaintId(105);
1489 let reason = PaintId(106);
1490
1491 let mut naturals = std::collections::HashMap::new();
1492 naturals.insert(header, 1);
1493 naturals.insert(preview, 0); naturals.insert(allow, 1);
1495 naturals.insert(options, 4);
1496 naturals.insert(spacer, 1);
1497 naturals.insert(reason, 1);
1498 let sizer = PerLeafSizer(naturals);
1499
1500 let tree = LayoutTree::vbox(vec![
1501 (Constraint::Fit, LayoutTree::leaf(header)),
1502 (Constraint::Fit, LayoutTree::leaf(preview)),
1503 (Constraint::Fit, LayoutTree::leaf(allow)),
1504 (Constraint::Fit, LayoutTree::leaf(options)),
1505 (Constraint::Fit, LayoutTree::leaf(spacer)),
1506 (Constraint::Fit, LayoutTree::leaf(reason)),
1507 ]);
1508
1509 for h in [8u16, 10, 12, 15, 20, 24] {
1513 let nat = tree.natural_size_with((80, h), &sizer);
1514 assert_eq!(nat.1, 8, "h={h}: dialog natural should equal sum-of-smalls");
1515 let dialog_h = nat.1.min(h);
1516 let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, dialog_h), &sizer);
1517 let used: u16 = result.values().map(|r| r.height).sum();
1518 assert_eq!(used, dialog_h, "h={h}: total {used} != dialog_h {dialog_h}");
1519 }
1520 }
1521
1522 #[test]
1523 fn fit_with_sizer_uses_leaf_natural_height() {
1524 let tree = LayoutTree::vbox(vec![
1525 (Constraint::Fit, LayoutTree::leaf(A)),
1526 (Constraint::Fill, LayoutTree::leaf(B)),
1527 ]);
1528 let sizer = FixedSizer(3);
1529 let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, 24), &sizer);
1530 assert_eq!(result[&A].height, 3, "Fit claims sizer-reported natural");
1531 assert_eq!(result[&B].height, 21, "Fill takes the remainder");
1532 }
1533
1534 #[test]
1535 fn fit_shares_with_fill_when_sizer_overflows() {
1536 let tree = LayoutTree::vbox(vec![
1539 (Constraint::Fit, LayoutTree::leaf(A)),
1540 (Constraint::Fill, LayoutTree::leaf(B)),
1541 ]);
1542 let sizer = FixedSizer(50);
1544 let result = resolve_layout_with(&tree, Rect::new(0, 0, 80, 10), &sizer);
1545 assert_eq!(result[&A].height, 5);
1546 assert_eq!(result[&B].height, 5);
1547 }
1548
1549 #[test]
1550 fn natural_size_with_sizer_reports_leaf_height() {
1551 let tree = LayoutTree::vbox(vec![(Constraint::Fit, LayoutTree::leaf(A))]);
1552 let sizer = FixedSizer(5);
1553 assert_eq!(tree.natural_size_with((80, 24), &sizer), (0, 5));
1554 }
1555
1556 #[test]
1557 fn natural_size_fill_contributes_zero_with_sizer() {
1558 let tree = LayoutTree::vbox(vec![(Constraint::Fill, LayoutTree::leaf(A))]);
1559 let sizer = FixedSizer(5);
1560 assert_eq!(tree.natural_size_with((80, 24), &sizer), (0, 0));
1563 }
1564
1565 #[test]
1566 fn zero_height_produces_empty_rects() {
1567 let tree = LayoutTree::vbox(vec![
1568 (Constraint::Length(30), LayoutTree::leaf(A)),
1569 (Constraint::Fill, LayoutTree::leaf(B)),
1570 ]);
1571 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 10));
1572 assert_eq!(result[&A].height, 10);
1573 assert_eq!(result[&B].height, 0);
1574 }
1575
1576 #[test]
1577 fn gap_inserts_spacing_between_children() {
1578 let tree = LayoutTree::vbox(vec![
1579 (Constraint::Fill, LayoutTree::leaf(A)),
1580 (Constraint::Fill, LayoutTree::leaf(B)),
1581 (Constraint::Fill, LayoutTree::leaf(C)),
1582 ])
1583 .with_gap(2);
1584 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1585 assert_eq!(result[&A], Rect::new(0, 0, 80, 7));
1586 assert_eq!(result[&B].top, 9);
1587 assert_eq!(result[&C].top, 18);
1588 }
1589
1590 #[test]
1591 fn border_insets_children_by_one_each_side() {
1592 let tree = LayoutTree::vbox(vec![(Constraint::Fill, LayoutTree::leaf(A))])
1593 .with_border(Border::SINGLE);
1594 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1595 assert_eq!(result[&A], Rect::new(1, 1, 78, 22));
1596 }
1597
1598 #[test]
1599 fn border_and_gap_compose() {
1600 let tree = LayoutTree::vbox(vec![
1601 (Constraint::Fill, LayoutTree::leaf(A)),
1602 (Constraint::Fill, LayoutTree::leaf(B)),
1603 ])
1604 .with_border(Border::SINGLE)
1605 .with_gap(1)
1606 .with_title("dialog");
1607 let result = resolve_layout(&tree, Rect::new(0, 0, 80, 24));
1608 assert_eq!(result[&A].top, 1);
1609 assert_eq!(result[&A].height + result[&B].height, 21);
1610 assert_eq!(result[&B].top, result[&A].top + result[&A].height + 1);
1611 }
1612
1613 #[test]
1614 fn natural_size_leaf_is_zero() {
1615 let tree = LayoutTree::leaf(A);
1616 assert_eq!(tree.natural_size((80, 24)), (0, 0));
1617 }
1618
1619 #[test]
1620 fn natural_size_vbox_lengths_sum_along_primary() {
1621 let tree = LayoutTree::vbox(vec![
1622 (Constraint::Length(5), LayoutTree::leaf(A)),
1623 (Constraint::Length(5), LayoutTree::leaf(B)),
1624 ]);
1625 assert_eq!(tree.natural_size((80, 24)), (0, 10));
1626 }
1627
1628 #[test]
1629 fn natural_size_hbox_lengths_sum_along_primary() {
1630 let tree = LayoutTree::hbox(vec![
1631 (Constraint::Length(20), LayoutTree::leaf(A)),
1632 (Constraint::Length(10), LayoutTree::leaf(B)),
1633 ]);
1634 assert_eq!(tree.natural_size((80, 24)), (30, 0));
1635 }
1636
1637 #[test]
1638 fn natural_size_vbox_gap_adds_to_primary() {
1639 let tree = LayoutTree::vbox(vec![
1640 (Constraint::Length(3), LayoutTree::leaf(A)),
1641 (Constraint::Length(4), LayoutTree::leaf(B)),
1642 (Constraint::Length(5), LayoutTree::leaf(C)),
1643 ])
1644 .with_gap(2);
1645 assert_eq!(tree.natural_size((80, 24)), (0, 16));
1646 }
1647
1648 #[test]
1649 fn natural_size_border_adds_two_each_axis() {
1650 let tree = LayoutTree::vbox(vec![(Constraint::Length(10), LayoutTree::leaf(A))])
1651 .with_border(Border::SINGLE);
1652 assert_eq!(tree.natural_size((80, 24)), (2, 12));
1653 }
1654
1655 #[test]
1656 fn natural_size_percentage_resolves_against_cap() {
1657 let tree = LayoutTree::vbox(vec![(Constraint::Percentage(50), LayoutTree::leaf(A))]);
1658 assert_eq!(tree.natural_size((80, 24)), (0, 12));
1659 }
1660
1661 #[test]
1662 fn natural_size_ratio_resolves_against_cap() {
1663 let tree = LayoutTree::hbox(vec![
1664 (Constraint::Ratio(1, 4), LayoutTree::leaf(A)),
1665 (Constraint::Ratio(1, 4), LayoutTree::leaf(B)),
1666 ]);
1667 assert_eq!(tree.natural_size((80, 24)), (40, 0));
1668 }
1669
1670 #[test]
1671 fn natural_size_fill_contributes_zero() {
1672 let tree = LayoutTree::vbox(vec![
1673 (Constraint::Length(3), LayoutTree::leaf(A)),
1674 (Constraint::Fill, LayoutTree::leaf(B)),
1675 ]);
1676 assert_eq!(tree.natural_size((80, 24)), (0, 3));
1677 }
1678
1679 #[test]
1680 fn natural_size_clamps_to_cap() {
1681 let tree = LayoutTree::vbox(vec![(Constraint::Length(100), LayoutTree::leaf(A))]);
1682 assert_eq!(tree.natural_size((80, 24)), (0, 24));
1683 }
1684
1685 #[test]
1686 fn leaves_in_order_walks_depth_first() {
1687 let tree = LayoutTree::vbox(vec![
1688 (Constraint::Fill, LayoutTree::leaf(A)),
1689 (
1690 Constraint::Length(5),
1691 LayoutTree::hbox(vec![
1692 (Constraint::Fill, LayoutTree::leaf(B)),
1693 (Constraint::Fill, LayoutTree::leaf(C)),
1694 ]),
1695 ),
1696 ]);
1697 assert_eq!(tree.leaves_in_order(), vec![A, B, C]);
1698 }
1699
1700 #[test]
1701 fn leaves_in_order_single_leaf() {
1702 let tree = LayoutTree::leaf(A);
1703 assert_eq!(tree.leaves_in_order(), vec![A]);
1704 }
1705
1706 #[test]
1707 fn leaf_carries_its_own_chrome() {
1708 let tree = LayoutTree::leaf(A)
1709 .with_border(Border::SINGLE)
1710 .with_title("hi");
1711 assert_eq!(tree.leaves_in_order(), vec![A]);
1712 assert!(tree.contains_leaf(A));
1713 match &tree {
1714 LayoutTree::Leaf { chrome, .. } => {
1715 assert!(chrome.border.is_some());
1716 assert!(chrome.title.is_some());
1717 }
1718 _ => panic!("expected Leaf with chrome"),
1719 }
1720 }
1721
1722 #[test]
1723 fn leaf_with_chrome_resolves_inside_inset_rect() {
1724 let tree = LayoutTree::leaf(A).with_border(Border::SINGLE);
1725 let area = Rect::new(0, 0, 10, 6);
1726 let rects = resolve_layout(&tree, area);
1727 let inner = rects.get(&A).copied().expect("leaf rect resolved");
1728 assert_eq!(inner, Rect::new(1, 1, 8, 4));
1729 }
1730
1731 #[test]
1732 fn contains_leaf_finds_direct_leaf() {
1733 let tree = LayoutTree::leaf(A);
1734 assert!(tree.contains_leaf(A));
1735 assert!(!tree.contains_leaf(B));
1736 }
1737
1738 #[test]
1739 fn contains_leaf_walks_nested_containers() {
1740 let tree = LayoutTree::vbox(vec![
1741 (Constraint::Fill, LayoutTree::leaf(A)),
1742 (
1743 Constraint::Length(5),
1744 LayoutTree::hbox(vec![(Constraint::Fill, LayoutTree::leaf(B))]),
1745 ),
1746 ]);
1747 assert!(tree.contains_leaf(A));
1748 assert!(tree.contains_leaf(B));
1749 assert!(!tree.contains_leaf(C));
1750 }
1751
1752 #[test]
1753 fn natural_size_nested_chrome_composes() {
1754 let tree = LayoutTree::vbox(vec![(
1755 Constraint::Length(5),
1756 LayoutTree::hbox(vec![
1757 (Constraint::Length(20), LayoutTree::leaf(A)),
1758 (Constraint::Length(10), LayoutTree::leaf(B)),
1759 ]),
1760 )])
1761 .with_border(Border::SINGLE);
1762 assert_eq!(tree.natural_size((80, 24)), (32, 7));
1763 }
1764
1765 #[test]
1766 fn paint_chrome_no_border_is_noop() {
1767 let mut grid = crate::grid::Grid::new(10, 5);
1768 let chrome = Chrome::default();
1769 paint_chrome(
1770 &mut grid,
1771 Rect::new(0, 0, 10, 5),
1772 &chrome,
1773 &crate::Theme::default(),
1774 );
1775 assert_eq!(grid.cell(0, 0).symbol, ' ');
1776 }
1777
1778 #[test]
1779 fn paint_chrome_single_border_draws_corners_and_edges() {
1780 let mut grid = crate::grid::Grid::new(10, 5);
1781 let chrome = Chrome {
1782 border: Some(Border::SINGLE),
1783 ..Chrome::default()
1784 };
1785 paint_chrome(
1786 &mut grid,
1787 Rect::new(0, 0, 10, 5),
1788 &chrome,
1789 &crate::Theme::default(),
1790 );
1791 assert_eq!(grid.cell(0, 0).symbol, '┌');
1792 assert_eq!(grid.cell(9, 0).symbol, '┐');
1793 assert_eq!(grid.cell(0, 4).symbol, '└');
1794 assert_eq!(grid.cell(9, 4).symbol, '┘');
1795 assert_eq!(grid.cell(5, 0).symbol, '─');
1796 assert_eq!(grid.cell(0, 2).symbol, '│');
1797 }
1798
1799 #[test]
1800 fn paint_chrome_title_paints_styled_spans() {
1801 use crate::grid::Color;
1802 use crate::line::{Line, Span};
1803 let mut grid = crate::grid::Grid::new(20, 3);
1804 let red = crate::grid::Style::new().fg(Color::Red);
1805 let chrome = Chrome {
1806 border: Some(Border::ROUNDED),
1807 title: Some(Line::from_spans([
1808 Span::raw("ok "),
1809 Span::styled("FAIL", red),
1810 ])),
1811 ..Chrome::default()
1812 };
1813 paint_chrome(
1814 &mut grid,
1815 Rect::new(0, 0, 20, 3),
1816 &chrome,
1817 &crate::Theme::default(),
1818 );
1819 assert_eq!(grid.cell(1, 0).symbol, 'o');
1820 assert_eq!(grid.cell(1, 0).style.fg, None);
1821 assert_eq!(grid.cell(4, 0).symbol, 'F');
1822 assert_eq!(grid.cell(4, 0).style.fg, Some(Color::Red));
1823 }
1824
1825 #[test]
1826 fn paint_chrome_title_lands_on_top_border() {
1827 let mut grid = crate::grid::Grid::new(20, 5);
1828 let chrome = Chrome {
1829 border: Some(Border::ROUNDED),
1830 title: Some("hello".into()),
1831 ..Chrome::default()
1832 };
1833 paint_chrome(
1834 &mut grid,
1835 Rect::new(0, 0, 20, 5),
1836 &chrome,
1837 &crate::Theme::default(),
1838 );
1839 assert_eq!(grid.cell(0, 0).symbol, '╭');
1840 assert_eq!(grid.cell(1, 0).symbol, 'h');
1841 assert_eq!(grid.cell(5, 0).symbol, 'o');
1842 assert_eq!(grid.cell(6, 0).symbol, '─');
1843 }
1844
1845 #[test]
1846 fn paint_chrome_truncates_title_to_inner_width() {
1847 let mut grid = crate::grid::Grid::new(8, 3);
1848 let chrome = Chrome {
1849 border: Some(Border::SINGLE),
1850 title: Some("muchtoolong".into()),
1851 ..Chrome::default()
1852 };
1853 paint_chrome(
1854 &mut grid,
1855 Rect::new(0, 0, 8, 3),
1856 &chrome,
1857 &crate::Theme::default(),
1858 );
1859 assert_eq!(grid.cell(0, 0).symbol, '┌');
1860 assert_eq!(grid.cell(1, 0).symbol, 'm');
1861 assert_eq!(grid.cell(6, 0).symbol, 'o');
1862 assert_eq!(grid.cell(7, 0).symbol, '┐');
1863 }
1864}