From 2358740deba939cb67b3e7f6b5177df405613b4c Mon Sep 17 00:00:00 2001 From: altsem Date: Wed, 29 Jul 2026 02:25:48 +0000 Subject: [PATCH] layout: fixes and usability improvmenets - set an implicit vertical root - `fill` opt needs no more than a [bool; 2] - remove unused trait bounds of Scalar - rename horizontal/vertical to row/col - separate leaf/container allow data in containers with `row/col_with()` - remove default column wrap, rows still wrap by default - more foolproof API surface, compute then iter - remove content size --- src/screen/mod.rs | 8 +- .../gitu__tests__rebase__rebase_menu.snap | 12 +- src/ui.rs | 45 +- src/ui/cmd_log.rs | 4 +- src/ui/item.rs | 2 +- src/ui/layout/direction.rs | 7 + src/ui/layout/mod.rs | 748 ++++++++++-------- src/ui/layout/node.rs | 138 ++-- ..._top_level_containers_stack_downwards.snap | 6 + src/ui/layout/vec2.rs | 39 +- src/ui/menu.rs | 18 +- src/ui/picker.rs | 72 +- 12 files changed, 591 insertions(+), 508 deletions(-) create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__top_level_containers_stack_downwards.snap diff --git a/src/screen/mod.rs b/src/screen/mod.rs index f887c32765..1866f39190 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -1,7 +1,7 @@ use crate::config::StyleConfig; use crate::style::Style; use crate::ui::layout::{LayoutTree, opts}; -use crate::ui::{UiItem, UiTree, layout_span}; +use crate::ui::{UiTree, layout_span}; use crate::{item_data::ItemData, ui}; use itertools::Itertools; @@ -308,9 +308,9 @@ impl Screen { highlighted: false, }; layout_item(&mut layout, self, false, view); - layout.compute([self.size.0, self.size.1]); layout + .compute([self.size.0, self.size.1]) .iter() .map(|item| item.pos[1] + item.size[1]) .max() @@ -551,7 +551,7 @@ struct ItemView { } pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, screen: &'a Screen, hide_cursor: bool) { - layout.vertical(None, opts().fill_x(), |layout| { + layout.col(opts().fill_x(), |layout| { for view in screen.item_views(screen.size) { layout_item(layout, screen, hide_cursor, view); } @@ -566,7 +566,7 @@ fn layout_item<'a>(layout: &mut UiTree<'a>, screen: &'a Screen, hide_cursor: boo let line_sel = line_selection_highlight(style, &line, is_line_sel); let bg = area_sel.patch(line_sel); - layout.horizontal(Some(UiItem::Style(bg)), opts().fill_x(), |layout| { + layout.row_with(bg, opts().fill_x(), |layout| { let gutter_char = if !hide_cursor && line.highlighted { gutter_char(style, is_line_sel, bg) } else { diff --git a/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap b/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap index 4d5df5764b..534927b6dc 100644 --- a/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap +++ b/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap @@ -7,6 +7,11 @@ expression: ctx.redact_buffer() Recent commits | b66a0bf other-branch origin/main add initial-file Author Name __ | | + | + | + | + | + | ────────────────────────────────────────────────────────────────────────────────| Rebase Arguments | a abort -a Autosquash (--autosquash) | @@ -17,9 +22,4 @@ expression: ctx.redact_buffer() -k Keep empty commits (--keep-empty) | -h Disable hooks (--no-verify) | -p Preserve merges (--preserve-merges) | - | - | - | - | - | -styles_hash: 814db1ed750863dd +styles_hash: 448a0978f1550760 diff --git a/src/ui.rs b/src/ui.rs index 58181d47ff..9d42cc8ed4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use crate::screen; use crate::style::{Color, Modifier, Style}; use crate::term::TermBackend; use crate::text_input::Status; -use crate::ui::layout::{LayoutItem, Measure}; +use crate::ui::layout::{LayoutItem, Measure, Payload}; use itertools::Itertools; use layout::LayoutTree; use layout::opts; @@ -25,22 +25,14 @@ const DASHES: &str = "─────────────────── const BLANKS: &str = " "; #[derive(Debug, Clone)] -pub(crate) enum UiItem<'a> { - Span(Cow<'a, str>, Style), - Style(Style), -} -pub(crate) type UiTree<'a> = LayoutTree>; +pub(crate) struct Span<'a>(pub(crate) Cow<'a, str>, pub(crate) Style); +pub(crate) type UiTree<'a> = LayoutTree, Style>; -impl Measure for UiItem<'_> { +impl Measure for Span<'_> { type Unit = u16; fn measure(&self) -> [u16; 2] { - match self { - UiItem::Span(text, _style) => [UnicodeWidthStr::width(text.as_ref()) as u16, 1], - // Styles are only ever attached to containers, which are sized by - // their children, so this is never the size of anything drawn. - UiItem::Style(_style) => [1, 1], - } + [UnicodeWidthStr::width(self.0.as_ref()) as u16, 1] } } @@ -48,13 +40,13 @@ pub(crate) fn ui(term: &mut TermBackend, state: &mut State) -> Res<()> { let size = term.size().unwrap(); let mut layout = UiTree::new(); - layout.vertical(None, opts(), |layout| { - layout.vertical(None, opts().fill_xy(), |layout| { + layout.col(opts(), |layout| { + layout.col(opts().fill_xy(), |layout| { let hide_cursor = state.picker.is_some(); screen::layout_screen(layout, state.screens.last().unwrap(), hide_cursor); }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { menu::layout_menu(layout, state, size.0 as usize); cmd_log::layout_cmd_log( layout, @@ -76,15 +68,14 @@ pub(crate) fn ui(term: &mut TermBackend, state: &mut State) -> Res<()> { }); }); - layout.compute([size.0, size.1]); + let computed = layout.compute([size.0, size.1]); - let mut items = layout.iter().collect::>(); + let mut items = computed.iter().collect::>(); items.sort_by_key(|item| [item.pos[1], item.pos[0]]); clear_blanks(term, size, items)?; term.flush().map_err(Error::Term)?; - layout.clear(); state.screens.last_mut().unwrap().size = size; @@ -106,7 +97,7 @@ fn layout_prompt<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { let prompt_style = Style::from(&state.config.style.prompt); repeat_chars(layout, width, DASHES, separator_style); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout_span( layout, ( @@ -157,7 +148,7 @@ fn layout_picker<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { /// Lays out `content` as a single row of its own. pub(crate) fn layout_line<'a>(layout: &mut UiTree<'a>, content: Cow<'a, str>, style: Style) { - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout_span(layout, (content, style)); }); } @@ -166,12 +157,12 @@ pub(crate) fn layout_span<'a>(layout: &mut UiTree<'a>, span: (Cow<'a, str>, Styl match span.0 { Cow::Borrowed(s) => { for word in words(s) { - layout.leaf(UiItem::Span(Cow::Borrowed(word), span.1)); + layout.leaf(Span(Cow::Borrowed(word), span.1)); } } Cow::Owned(s) => { for word in words(&s) { - layout.leaf(UiItem::Span(Cow::Owned(word.into()), span.1)); + layout.leaf(Span(Cow::Owned(word.into()), span.1)); } } } @@ -190,7 +181,7 @@ pub(crate) fn repeat_chars(layout: &mut UiTree, count: usize, chars: &'static st let full = count / grapheme_count; let partial = count % grapheme_count; - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { for _ in 0..full { layout_span(layout, (chars.into(), style)); } @@ -212,7 +203,7 @@ pub(crate) fn repeat_chars(layout: &mut UiTree, count: usize, chars: &'static st fn clear_blanks( term: &mut TermBackend, size: (u16, u16), - items: Vec, u16>>, + items: Vec, Style>, u16>>, ) -> Result<(), Error> { let mut at = [0, 0]; let mut bg = Style::new(); @@ -227,14 +218,14 @@ fn clear_blanks( blank_until(term, &mut at, [0, pos[1]], size.0, bg, bg_end)?; match data { - UiItem::Span(text, style) => { + Payload::Leaf(Span(text, style)) => { blank_until(term, &mut at, pos, size.0, bg, bg_end)?; term.queue_move_cursor(pos[0], pos[1])?; term.queue_print(text, style)?; at[0] = pos[0].saturating_add(item_size[0]); } - UiItem::Style(style) => { + Payload::Container(style) => { bg = *style; bg_end = pos[1].saturating_add(item_size[1]); } diff --git a/src/ui/cmd_log.rs b/src/ui/cmd_log.rs index 6173f6c968..df2406a5f1 100644 --- a/src/ui/cmd_log.rs +++ b/src/ui/cmd_log.rs @@ -19,7 +19,7 @@ pub(crate) fn layout_cmd_log<'a>( repeat_chars(layout, width, DASHES, Style::from(&config.style.separator)); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { for entry in &log.entries { layout_entry(layout, entry, config); } @@ -30,7 +30,7 @@ pub(crate) fn layout_cmd_log<'a>( fn layout_entry<'a>(layout: &mut UiTree<'a>, entry: &Arc>, config: &Config) { match &*entry.read().unwrap() { CmdLogEntry::Cmd { args, out } => { - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout_span( layout, ( diff --git a/src/ui/item.rs b/src/ui/item.rs index 53f1142928..b97de88680 100644 --- a/src/ui/item.rs +++ b/src/ui/item.rs @@ -78,7 +78,7 @@ pub(crate) fn layout_item<'a>( ), ); - layout.horizontal(None, opts().fill_x(), |layout| { + layout.row(opts().fill_x(), |layout| { for reference in associated_references { layout_span(layout, (" ".into(), base)); layout_reference(layout, reference, config, base); diff --git a/src/ui/layout/direction.rs b/src/ui/layout/direction.rs index 81251a05d2..00df486f7a 100644 --- a/src/ui/layout/direction.rs +++ b/src/ui/layout/direction.rs @@ -15,4 +15,11 @@ impl Direction { Direction::Vertical => Vec2(U::ZERO, U::ONE), } } + + pub(crate) fn flip(&self) -> Self { + match self { + Direction::Horizontal => Direction::Vertical, + Direction::Vertical => Direction::Horizontal, + } + } } diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs index 95757d23c3..d86f5794d0 100644 --- a/src/ui/layout/mod.rs +++ b/src/ui/layout/mod.rs @@ -11,7 +11,8 @@ use vec2::Vec2; pub use node::{Opts, opts}; pub use vec2::Scalar; -const ROOT_INDEX: usize = usize::MAX; +const ROOT: usize = 0; +const NO_PARENT: usize = usize::MAX; /// Sizes an item as it is added to the tree, in whichever unit the layout is /// measured in. @@ -21,39 +22,34 @@ pub trait Measure { fn measure(&self) -> [Self::Unit; 2]; } +/// LayoutTree contains the intermediate data used for computing a layout. +/// `L` is the Leaf data type. +/// `C` is the Container data type. #[derive(Debug)] -pub struct LayoutTree { - data: Vec>, +pub struct LayoutTree { + data: Vec>, index: TreeIndex, } #[derive(Debug)] -pub(crate) struct TreeIndex { +struct TreeIndex { parents: Vec, current_parent: usize, } impl TreeIndex { - pub(crate) fn new() -> Self { + fn new() -> Self { TreeIndex { parents: Vec::new(), - current_parent: ROOT_INDEX, + current_parent: ROOT, } } - pub(crate) fn iter_roots(&self) -> impl Iterator { - self.parents - .first() - .map(|_node| 0) - .into_iter() - .chain(self.iter_siblings_after(0)) - } - - pub(crate) fn iter(&self) -> impl Iterator { + fn iter(&self) -> impl Iterator { 0..self.parents.len() } - pub(crate) fn iter_siblings_after(&self, index: usize) -> impl Iterator { + fn iter_siblings_after(&self, index: usize) -> impl Iterator { let start = index + 1; let parent_index = self.parents[index]; @@ -65,7 +61,7 @@ impl TreeIndex { .map(move |(i, _depth)| start + i) } - pub(crate) fn iter_children(&self, index: usize) -> impl Iterator { + fn iter_children(&self, index: usize) -> impl Iterator { let start = index + 1; self.parents[start..] @@ -75,49 +71,51 @@ impl TreeIndex { .filter(move |&(_i, &parent)| parent == index) .map(move |(i, _depth)| start + i) } - - #[allow(dead_code)] - pub(crate) fn iter_all_children(&self, index: usize) -> impl Iterator { - let start = index + 1; - - self.parents[start..] - .iter() - .take_while(move |&&parent| parent >= index) - .enumerate() - .map(move |(i, _depth)| start + i) - } } #[derive(Debug, PartialEq, Copy, Clone)] -pub enum Pass { +enum Pass { Fit, Fill, } -impl LayoutTree { +impl Default for LayoutTree { + fn default() -> Self { + Self::new() + } +} + +impl LayoutTree { pub fn new() -> Self { - LayoutTree { + let mut tree = LayoutTree { data: Vec::new(), index: TreeIndex::new(), - } + }; + + tree.push_root(); + tree } - pub fn clear(&mut self) { - self.data.clear(); - self.index.parents.clear(); - self.index.current_parent = ROOT_INDEX; + fn push_root(&mut self) { + self.data.push(Node { + data: NodeData::Container(None), + dir: Direction::Vertical, + opts: Opts::new(), + size: Vec2::zero(), + fitted: Vec2::zero(), + pos: None, + }); + + self.index.parents.push(NO_PARENT); + self.index.current_parent = ROOT; } - pub(crate) fn add(&mut self, data: Node) { + fn add(&mut self, data: Node) { self.data.push(data); self.index.parents.push(self.index.current_parent); } - pub(crate) fn add_with_children( - &mut self, - data: Node, - insert_fn: F, - ) { + fn add_with_children(&mut self, data: Node, insert_fn: F) { self.add(data); let our_parent = self.index.current_parent; self.index.current_parent = self.index.parents.len() - 1; @@ -126,100 +124,90 @@ impl LayoutTree { self.index.current_parent = our_parent; } -} -impl Default for LayoutTree { - fn default() -> Self { - Self::new() + /// A container whose children flow left to right. + pub fn row(&mut self, opts: Opts, layout_fn: F) { + self.container(Direction::Horizontal, None, opts, layout_fn); } -} -impl LayoutTree { - pub fn horizontal)>( - &mut self, - data: Option, - opts: Opts, - layout_fn: F, - ) -> &mut Self { - { - let ode = node::Node { - data, - opts: Opts { - dir: Direction::Horizontal, - ..opts - }, - size: Vec2::zero(), - content: Vec2::zero(), - pos: None, - }; + /// A [`LayoutTree::row`] carrying `data`, for annotating the area its + /// children end up occupying. + pub fn row_with(&mut self, data: C, opts: Opts, layout_fn: F) { + self.container(Direction::Horizontal, Some(data), opts, layout_fn); + } - self.add_with_children(ode, layout_fn); - self - } + /// A container whose children flow top to bottom. + pub fn col(&mut self, opts: Opts, layout_fn: F) { + self.container(Direction::Vertical, None, opts, layout_fn); + } + + /// A [`LayoutTree::col`] carrying `data`, for annotating the area its + /// children end up occupying. + #[allow(dead_code)] + pub fn col_with(&mut self, data: C, opts: Opts, layout_fn: F) { + self.container(Direction::Vertical, Some(data), opts, layout_fn); } - pub fn vertical)>( + fn container( &mut self, - data: Option, - opts: Opts, + dir: Direction, + data: Option, + opts: Opts, layout_fn: F, - ) -> &mut Self { - { - let node = node::Node { - data, - opts: Opts { - dir: Direction::Vertical, - ..opts - }, + ) { + self.add_with_children( + node::Node { + data: NodeData::Container(data), + dir, + opts, size: Vec2::zero(), - content: Vec2::zero(), + fitted: Vec2::zero(), pos: None, - }; - - self.add_with_children(node, layout_fn); - self - } + }, + layout_fn, + ); } - pub fn leaf(&mut self, data: T) -> &mut Self { - let size = data.measure(); + pub fn leaf(&mut self, data: L) { + let size = Vec2::from(data.measure()); self.add(node::Node { - data: Some(data), + data: NodeData::Leaf(data), + dir: Direction::Horizontal, opts: opts(), - size: size.into(), - content: size.into(), + size, + fitted: size, pos: None, }); - - self } - pub fn compute(&mut self, avail_size: [T::Unit; 2]) { - let Some(root) = self.index.iter_roots().next() else { - panic!("no root"); - }; - + /// Places every node within `avail_size`, handing back the result to read + /// positions off. + pub fn compute(&mut self, avail_size: [L::Unit; 2]) -> Computed<'_, L, C> { let size = Vec2::from(avail_size); - self.data[root].pos = Some(Vec2::zero()); + self.data[ROOT].pos = Some(Vec2::zero()); for pass in [Pass::Fit, Pass::Fill] { - if let Some(root_size) = self.compute_subtree(root, Vec2::zero(), size, pass) { - self.data[root].size = root_size; + if let Some(root_size) = self.compute_subtree(ROOT, Vec2::zero(), size, pass) { + self.data[ROOT].size = root_size; } } + + Computed { tree: self } } fn compute_subtree( &mut self, parent: usize, - outer_start: Vec2, - outer_avail_size: Vec2, + outer_start: Vec2, + outer_avail_size: Vec2, pass: Pass, - ) -> Option> { + ) -> Option> { let child = self.index.iter_children(parent).next()?; + let parent_dir = self.data[parent].dir; let parent_opts = self.data[parent].opts; - let main_axis = parent_opts.dir.axis(); + let wrap = self.data[parent].is_wrapping(); + let main_axis = parent_dir.axis(); let cross_axis = main_axis.flip(); let padding = Vec2::splat(parent_opts.pad) * main_axis; let avail_size = outer_avail_size @@ -229,13 +217,10 @@ impl LayoutTree { let mut current_child = Some(child); let mut cursor = Vec2::zero(); let mut size = Vec2::zero(); - // Intrinsic extent, accumulated independently of any fill shrinking. - let mut content_cursor = Vec2::zero(); - let mut content = Vec2::zero(); let fill_avail = match pass { Pass::Fit => Vec2::zero(), - Pass::Fill => avail_size.saturating_sub(self.data[parent].size), + Pass::Fill => avail_size.saturating_sub(self.data[parent].fitted), }; let mut main_fill_iter = self.dist_main_fill(parent, fill_avail); @@ -243,37 +228,25 @@ impl LayoutTree { while let Some(child) = current_child { let child_start = outer_start + padding + cursor; - let is_main_fill = self.data[child].opts.is_main_fill(&parent_opts); - let is_cross_fill = self.data[child].opts.is_cross_fill(&parent_opts); + let is_main_fill = self.data[child].opts.is_main_fill(parent_dir); + let is_cross_fill = self.data[child].opts.is_cross_fill(parent_dir); let mut child_size = match pass { Pass::Fit => { let child_avail_size = avail_size.saturating_sub(cursor); - // `flow` is the shrinkable extent, `child_content` the intrinsic one. - // They only differ where a descendant fills an axis. - let (flow, child_content) = + let fitted = match self.compute_subtree(child, child_start, child_avail_size, pass) { - Some(flow) => (flow, self.data[child].content), - None => (self.data[child].size, self.data[child].size), + Some(fitted) => fitted, + None => self.data[child].size, }; - self.data[child].content = child_content; - content = content.max(content_cursor + child_content); - content_cursor += main_axis * (Vec2::splat(parent_opts.gap) + child_content); - - // On an axis this child fills it may shrink, so take the flow - // extent. On an axis it does not fill, its size is its content. - let fill_mask = self.data[child].opts.fill; - let content_mask = Vec2::one().saturating_sub(fill_mask); - let resolved = flow * fill_mask + child_content * content_mask; - if is_main_fill { // Zero-out the main axis that this child will later fill, // this enables it to shrink. - resolved * cross_axis + fitted * cross_axis } else { - resolved + fitted } } Pass::Fill => { @@ -288,15 +261,15 @@ impl LayoutTree { sum }; - let fill_mask = self.data[child].opts.fill; - let content_mask = Vec2::one().saturating_sub(fill_mask); + let fill_mask = self.data[child].opts.fill_mask(); + let fixed_mask = Vec2::one().saturating_sub(fill_mask); let remaining = avail_size.saturating_sub(cursor); let granted = remaining.min(self.data[child].size + fill); - let child_avail_size = granted * fill_mask + remaining * content_mask; + let child_avail_size = granted * fill_mask + remaining * fixed_mask; match self.compute_subtree(child, child_start, child_avail_size, pass) { - Some(placed) => granted * fill_mask + placed * content_mask, + Some(placed) => granted * fill_mask + placed * fixed_mask, None => self.data[child].size, } } @@ -310,7 +283,7 @@ impl LayoutTree { // Child doesn't fit where cursor currently is let next_line = size * cross_axis; - if (next_line + child_size).fits(avail_size) { + if wrap && (next_line + child_size).fits(avail_size) { // Fits completely on next line cursor = next_line; child_pos = Some(outer_start + padding + cursor); @@ -332,13 +305,11 @@ impl LayoutTree { current_child = self.index.iter_siblings_after(child).next(); } - // Content is accumulated without wrapping, so it can come out smaller on - // the cross axis than what was actually laid out. Never report less than - // the placed extent. - let content = content.max(size); - size += padding + padding; - self.data[parent].content = content + padding + padding; + + if pass == Pass::Fit { + self.data[parent].fitted = size; + } Some(size) } @@ -346,20 +317,21 @@ impl LayoutTree { fn dist_main_fill( &self, parent: usize, - size_to_distribute: Vec2, - ) -> impl Iterator> + use { - let axis = self.data[parent].opts.dir.axis(); + size_to_distribute: Vec2, + ) -> impl Iterator> + use { + let axis = self.data[parent].dir.axis(); let along_axis = size_to_distribute * axis; let count = self .index .iter_children(parent) - .filter(|&child| self.data[child].opts.is_main_fill(&self.data[parent].opts)) - .count() as u16; + .filter(|&child| self.data[child].opts.is_main_fill(self.data[parent].dir)) + .count(); + + let divisor = Vec2::splat((0..count).fold(L::Unit::ZERO, |sum, _| sum + L::Unit::ONE)); let (quot, rem) = if count > 0 { - let count = Vec2::splat(count.into()); - let quot = along_axis / count; - let rem = along_axis - quot * count; + let quot = along_axis / divisor; + let rem = along_axis - quot * divisor; (quot, rem) } else { (Vec2::zero(), Vec2::zero()) @@ -367,33 +339,51 @@ impl LayoutTree { iter::once(quot + rem) .chain(iter::once(quot).cycle()) - .take(count as usize) - } - - pub fn iter(&self) -> impl Iterator> { - self.index.iter().filter_map(|index| { - let Node { - data: Some(data), - opts: _, - size, - content: _, - pos, - } = &self.data[index] - else { - return None; + .take(count) + } +} + +/// A laid-out [`LayoutTree`] +#[derive(Debug)] +pub struct Computed<'a, L: Measure, C> { + tree: &'a LayoutTree, +} + +impl Computed<'_, L, C> { + pub fn iter(&self) -> impl Iterator, L::Unit>> { + self.tree.index.iter().filter_map(|index| { + let node = &self.tree.data[index]; + + let data = match &node.data { + NodeData::Leaf(leaf) => Payload::Leaf(leaf), + NodeData::Container(Some(container)) => Payload::Container(container), + NodeData::Container(None) => return None, }; - if size.0 == T::Unit::ZERO || size.1 == T::Unit::ZERO { + if node.size.0 == L::Unit::ZERO || node.size.1 == L::Unit::ZERO { return None; } Some(LayoutItem { data, - pos: (*pos)?.into(), - size: (*size).into(), + pos: node.pos?.into(), + size: node.size.into(), }) }) } + + /// The extent the tree came to, which is at most what it was given. + #[allow(dead_code)] + pub fn size(&self) -> [L::Unit; 2] { + self.tree.data[ROOT].size.into() + } +} + +/// The inserted data read back from the tree. Can be either a Leaf or a Container. +#[derive(Debug)] +pub enum Payload<'a, L, C> { + Leaf(&'a L), + Container(&'a C), } #[derive(Debug)] @@ -410,6 +400,16 @@ mod tests { use super::*; + /// The tree under test: `&str` for both leaves and container annotations. + type TestTree = LayoutTree<&'static str, &'static str>; + + /// Test trees use one type for both payloads, so assertions can read either. + fn payload(data: Payload<'_, &'static str, &'static str>) -> &'static str { + match data { + Payload::Leaf(text) | Payload::Container(text) => text, + } + } + impl Measure for &str { type Unit = u16; @@ -418,8 +418,6 @@ mod tests { } } - /// An item measured in a non-integer unit. Sizes are chosen as binary - /// fractions so that the arithmetic below is exact. #[derive(Debug, Clone)] struct Fractional(f32, f32); @@ -433,15 +431,15 @@ mod tests { #[test] fn float_units_are_placed_at_fractional_offsets() { - let mut layout = LayoutTree::new(); + let mut layout: LayoutTree = LayoutTree::new(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout.leaf(Fractional(2.5, 1.0)); layout.leaf(Fractional(2.5, 1.0)); }); - layout.compute([5.0, 1.0]); - let mut iter = layout.iter().map(|item| (item.pos, item.size)); + let computed = layout.compute([5.0, 1.0]); + let mut iter = computed.iter().map(|item| (item.pos, item.size)); assert_eq!(Some(([0.0, 0.0], [2.5, 1.0])), iter.next()); assert_eq!(Some(([2.5, 0.0], [2.5, 1.0])), iter.next()); assert_eq!(None, iter.next()); @@ -449,19 +447,17 @@ mod tests { #[test] fn float_fill_distributes_the_whole_extent() { - let mut layout = LayoutTree::new(); + let mut layout: LayoutTree = LayoutTree::new(); - layout.vertical(None, opts(), |layout| { - for _ in 0..3 { - layout.horizontal(None, opts().fill_y(), |layout| { - layout.leaf(Fractional(1.0, 1.0)); - }); - } - }); + for _ in 0..3 { + layout.row(opts().fill_y(), |layout| { + layout.leaf(Fractional(1.0, 1.0)); + }); + } // 10 does not divide evenly by 3, which is where a `%`-derived // remainder would hand the first filler space already shared out. - let shares = layout.dist_main_fill(0, Vec2(0.0, 10.0)).collect_vec(); + let shares = layout.dist_main_fill(ROOT, Vec2(0.0, 10.0)).collect_vec(); assert_eq!(3, shares.len()); let total: f32 = shares.iter().map(|share| share.1).sum(); @@ -470,23 +466,18 @@ mod tests { /// Render the layout to a string for testing purposes. /// Note: ASCII only — does not support Unicode beyond single-byte chars. - fn render_to_string(layout: LayoutTree<&'static str>) -> String { - let Some(root) = layout.index.iter_roots().next() else { - panic!("no root"); - }; - - let node = &layout.data[root]; - let width = node.size.0 as usize; - let height = node.size.1 as usize; + fn render_to_string(computed: Computed<'_, &'static str, &'static str>) -> String { + let [width, height] = computed.size(); + let (width, height) = (width as usize, height as usize); let mut grid = vec![' '; height * width]; - for LayoutItem { data, pos, size } in layout.iter() { + for LayoutItem { data, pos, size } in computed.iter() { let x0 = pos[0] as usize; let y0 = pos[1] as usize; let item_width = size[0] as usize; - for (i, c) in data.chars().take(item_width).enumerate() { + for (i, c) in payload(data).chars().take(item_width).enumerate() { grid[y0 * width + (x0 + i)] = c; } } @@ -498,73 +489,66 @@ mod tests { #[test] fn test_iter_distribute_size_no_flex() { - let mut layout = LayoutTree::new(); - layout.vertical(None, opts(), |layout| { - // Neither should grow - layout.leaf("Hello"); - layout.leaf("Hello"); - }); + let mut layout = TestTree::new(); + // Neither should grow + layout.leaf("Hello"); + layout.leaf("Hello"); - let mut iter = layout.dist_main_fill(0, Vec2(10, 3)); + let mut iter = layout.dist_main_fill(ROOT, Vec2(10, 3)); assert_eq!(None, iter.next()); } #[test] fn test_iter_distribute_size_one_flex() { - let mut layout = LayoutTree::new(); - layout.vertical(None, opts(), |layout| { - layout.horizontal(None, opts(), |layout| { - layout.leaf("One"); - }); - // This should shrink to 0 vertically and then grow to 3 - layout.horizontal(None, opts().fill_y(), |layout| { - layout.leaf("Two"); - }); + let mut layout = TestTree::new(); + layout.row(opts(), |layout| { + layout.leaf("One"); + }); + // This should shrink to 0 vertically and then grow to 3 + layout.row(opts().fill_y(), |layout| { + layout.leaf("Two"); }); - let mut iter = layout.dist_main_fill(0, Vec2(10, 3)); + let mut iter = layout.dist_main_fill(ROOT, Vec2(10, 3)); assert_eq!(Some(Vec2(0, 3)), iter.next()); assert_eq!(None, iter.next()); } #[test] fn test_iter_distribute_size_all_flex() { - let mut layout = LayoutTree::new(); - layout.vertical(None, opts(), |layout| { - // Both should grow, favoring the first - layout.horizontal(None, opts().fill_y(), |layout| { - layout.leaf("One"); - }); - layout.horizontal(None, opts().fill_y(), |layout| { - layout.leaf("Two"); - }); + let mut layout = TestTree::new(); + // Both should grow, favoring the first + layout.row(opts().fill_y(), |layout| { + layout.leaf("One"); + }); + layout.row(opts().fill_y(), |layout| { + layout.leaf("Two"); }); - let mut iter = layout.dist_main_fill(0, Vec2(10, 5)); + let mut iter = layout.dist_main_fill(ROOT, Vec2(10, 5)); assert_eq!(Some(Vec2(0, 3)), iter.next()); assert_eq!(Some(Vec2(0, 2)), iter.next()); } #[test] fn single_text() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("Hello"); layout.leaf("lol"); }); - layout.compute([5, 2]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([5, 2]))); } #[test] fn fill_node_wraps_within_the_extent_it_gets() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { // Measures as one row at the full width, but only gets 6 columns. - layout.horizontal(None, opts().fill_x(), |layout| { + layout.row(opts().fill_x(), |layout| { layout.leaf("hello"); layout.leaf(" "); layout.leaf("world"); @@ -572,117 +556,226 @@ mod tests { layout.leaf("author"); }); - layout.compute([12, 2]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([12, 2]))); } #[test] fn horizontal_layout() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout.leaf("A"); layout.leaf("BB"); layout.leaf("CCC"); }); - layout.compute([6, 1]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([6, 1]))); } #[test] fn vertical_layout() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("First"); layout.leaf("Second"); layout.leaf("Third"); }); - layout.compute([6, 3]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([6, 3]))); } #[test] fn nested_layouts() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { // 0 - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { // 1 layout.leaf("A"); // 2 layout.leaf("B"); // 3 }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { // 4 layout.leaf("C"); // 5 layout.leaf("D"); // 6 }); }); - layout.compute([2, 2]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([2, 2]))); } #[test] - fn clear_layout() { - let mut layout = LayoutTree::new(); + fn every_top_level_node_is_laid_out() { + let mut layout = TestTree::new(); - layout.leaf("Test"); + layout.leaf("first"); + layout.leaf("second"); - layout.clear(); - assert_eq!(layout.iter().count(), 0); + let computed = layout.compute([20, 2]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos)); + assert_eq!(Some(("first", [0, 0])), iter.next()); + assert_eq!(Some(("second", [0, 1])), iter.next()); + assert_eq!(None, iter.next()); + } + + #[test] + fn top_level_containers_stack_downwards() { + let mut layout = TestTree::new(); + + layout.row(opts(), |layout| { + layout.leaf("aaa"); + }); + layout.row(opts(), |layout| { + layout.leaf("bbb"); + }); + + insta::assert_snapshot!(render_to_string(layout.compute([20, 2]))); + } + + #[test] + fn empty_layout_computes_without_panicking() { + let mut layout = TestTree::new(); + + assert_eq!(0, layout.compute([20, 2]).iter().count()); } #[test] fn out_of_bounds_horizontal() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { - layout.horizontal(None, opts(), |layout| { + layout.col(opts(), |layout| { + layout.row(opts(), |layout| { layout.leaf("12345"); layout.leaf("The very start of this will be visible (a T)"); }); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout.leaf("123456"); layout.leaf("This is completely outside of the layout and ignored"); }); }); - layout.compute([6, 4]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([6, 4]))); } #[test] fn test_horizontal_wrap() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout.leaf("AAA"); layout.leaf("BBB"); layout.leaf("CCC"); }); - layout.compute([6, 2]); - let result = render_to_string(layout); + let result = render_to_string(layout.compute([6, 2])); println!("Result:\n{}", result); // Should wrap: "AAABBB" on first line, "CCC" on second line assert_eq!(result, "AAABBB\nCCC"); } + #[test] + fn a_wrapped_row_does_not_reserve_space_it_never_uses() { + let mut layout = TestTree::new(); + + layout.col(opts().fill_y(), |layout| { + layout.leaf("top"); + }); + layout.col(opts(), |layout| { + layout.row(opts(), |layout| { + layout.col(opts(), |layout| { + layout.row(opts(), |layout| { + layout.leaf("aaaa"); + layout.leaf("bbbb"); + }); + }); + layout.leaf("cc"); + }); + }); + + let computed = layout.compute([6, 6]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos)); + + // The bottom block is two rows, so it sits flush at rows 4 and 5. + assert_eq!(Some(("top", [0, 0])), iter.next()); + assert_eq!(Some(("aaaa", [0, 4])), iter.next()); + assert_eq!(Some(("bbbb", [0, 5])), iter.next()); + assert_eq!(Some(("cc", [4, 4])), iter.next()); + assert_eq!(None, iter.next()); + } + + /// A filling node has its main axis zeroed so that it can shrink, so its + /// own size is not what is left over for the fill pass to share out. + /// Sharing out the whole extent instead leaves nothing for a sibling that + /// doesn't fill. + #[test] + fn a_fill_inside_a_fill_leaves_room_for_its_siblings() { + let mut layout = TestTree::new(); + + layout.col(opts().fill_y(), |layout| { + layout.col(opts().fill_y(), |layout| { + layout.leaf("a"); + }); + layout.col(opts(), |layout| { + layout.leaf("b"); + }); + }); + + let computed = layout.compute([3, 5]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos)); + + // The inner fill takes rows 0..4, leaving the last row for "b". + assert_eq!(Some(("a", [0, 0])), iter.next()); + assert_eq!(Some(("b", [0, 4])), iter.next()); + assert_eq!(None, iter.next()); + } + + #[test] + fn a_column_clips_rather_than_starting_a_new_column() { + let mut layout = TestTree::new(); + + layout.col(opts(), |layout| { + layout.leaf("aa"); + layout.leaf("bb"); + layout.leaf("cc"); + }); + + let computed = layout.compute([4, 2]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos)); + assert_eq!(Some(("aa", [0, 0])), iter.next()); + assert_eq!(Some(("bb", [0, 1])), iter.next()); + assert_eq!(None, iter.next()); + } + + #[test] + fn a_column_wraps_into_columns_when_asked_to() { + let mut layout = TestTree::new(); + + layout.col(opts().wrap(), |layout| { + layout.leaf("aa"); + layout.leaf("bb"); + layout.leaf("cc"); + }); + + let computed = layout.compute([4, 2]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos)); + assert_eq!(Some(("aa", [0, 0])), iter.next()); + assert_eq!(Some(("bb", [0, 1])), iter.next()); + assert_eq!(Some(("cc", [2, 0])), iter.next()); + assert_eq!(None, iter.next()); + } + #[test] fn test_wrap_before_truncate() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout.leaf("AAAA"); layout.leaf("BBBB"); }); - layout.compute([6, 2]); - let result = render_to_string(layout); + let result = render_to_string(layout.compute([6, 2])); println!("Result:\n{}", result); // With 6 chars width and 2 rows: // "AAAA" fits (4 chars), then "BBBB" doesn't fit in remaining 2 chars @@ -692,11 +785,11 @@ mod tests { #[test] fn nested_grow_wrap() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { - layout.vertical(None, opts().fill_y(), |layout| { - layout.horizontal(None, opts(), |layout| { + layout.col(opts(), |layout| { + layout.col(opts().fill_y(), |layout| { + layout.row(opts(), |layout| { layout.leaf("word1"); layout.leaf("word2"); layout.leaf("word3"); @@ -704,8 +797,8 @@ mod tests { }); }); - layout.compute([10, 2]); - let mut iter = layout.iter().map(|e| (*e.data, e.pos, e.size)); + let computed = layout.compute([10, 2]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos, e.size)); assert_eq!(Some(("word1", [0, 0], [5, 1])), iter.next()); assert_eq!(Some(("word2", [5, 0], [5, 1])), iter.next()); assert_eq!(Some(("word3", [0, 1], [5, 1])), iter.next()); @@ -714,15 +807,14 @@ mod tests { #[test] fn test_no_trailing_newline() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("Line 1"); layout.leaf("Line 2"); }); - layout.compute([10, 2]); - let result = render_to_string(layout); + let result = render_to_string(layout.compute([10, 2])); println!("Result bytes: {:?}", result.as_bytes()); println!("Result repr: {:?}", result); // Should not have trailing newline @@ -732,91 +824,88 @@ mod tests { #[test] fn out_of_bounds_vertical() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { - layout.vertical(None, opts(), |layout| { + layout.row(opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("1"); layout.leaf("2"); }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("1"); layout.leaf("2"); layout.leaf("X"); }); }); - layout.compute([2, 2]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([2, 2]))); } #[test] fn unicode_text_width() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts(), |layout| { - layout.leaf("café").leaf("naïve"); + layout.row(opts(), |layout| { + layout.leaf("café"); + layout.leaf("naïve"); }); - layout.compute([10, 1]); - let items: Vec<_> = layout.iter().collect(); + let computed = layout.compute([10, 1]); + let items: Vec<_> = computed.iter().collect(); assert_eq!(items[0].size, [4, 1]); // café has 4 graphemes } #[test] fn horizontal_gap() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.horizontal(None, opts().gap(2), |layout| { + layout.row(opts().gap(2), |layout| { layout.leaf("one"); layout.leaf("two"); }); - layout.compute([8, 1]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([8, 1]))); } #[test] fn vertical_gap() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts().gap(1), |layout| { + layout.col(opts().gap(1), |layout| { layout.leaf("one"); layout.leaf("two"); }); - layout.compute([3, 3]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([3, 3]))); } #[test] fn grow() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { - layout.vertical(None, opts().fill_y(), |layout| { + layout.col(opts(), |layout| { + layout.col(opts().fill_y(), |layout| { layout.leaf("flex"); }); layout.leaf("actual"); }); - layout.compute([8, 3]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([8, 3]))); } #[test] fn nested_grow_preserves_cross_axis() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(Some("root"), opts(), |layout| { - layout.horizontal(Some("grow"), opts().fill_xy(), |layout| { + layout.col_with("root", opts(), |layout| { + layout.row_with("grow", opts().fill_xy(), |layout| { layout.leaf("hello"); }); }); - layout.compute([20, 10]); + let computed = layout.compute([20, 10]); - let mut iter = layout.iter().map(|e| (*e.data, e.pos, e.size)); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos, e.size)); assert_eq!(Some(("root", [0, 0], [20, 10])), iter.next()); assert_eq!(Some(("grow", [0, 0], [20, 10])), iter.next()); @@ -826,75 +915,73 @@ mod tests { #[test] fn overflow() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("one"); layout.leaf("twoooo"); layout.leaf("three"); }); - layout.compute([6, 1]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([6, 1]))); } #[test] fn shrink() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { - layout.vertical(None, opts().fill_y(), |layout| { + layout.col(opts(), |layout| { + layout.col(opts().fill_y(), |layout| { layout.leaf("flex 1"); layout.leaf("flex 2"); }); layout.leaf("actual"); }); - layout.compute([6, 2]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([6, 2]))); } #[test] fn shrinks_nested() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { - layout.vertical(None, opts().fill_y(), |layout| { - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { + layout.col(opts().fill_y(), |layout| { + layout.col(opts(), |layout| { layout.leaf("This should not be visible'"); }); }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("WEEEEE"); }); }); - layout.compute([40, 1]); - let mut iter = layout.iter().map(|e| (*e.data, e.pos, e.size)); + let computed = layout.compute([40, 1]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos, e.size)); assert_eq!(Some(("WEEEEE", [0, 0], [6, 1])), iter.next()); assert_eq!(None, iter.next()); } #[test] fn nested_horizontal_fill_does_not_hide_siblings() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { // 0,0 -> 0,5 - layout.vertical(None, opts().fill_y(), |layout| { + layout.col(opts().fill_y(), |layout| { // 0,1 -> 0,1 - layout.horizontal(None, opts().fill_x(), |layout| { + layout.row(opts().fill_x(), |layout| { // 0,1 -> 0,1 - layout.horizontal(None, opts().fill_x(), |layout| { + layout.row(opts().fill_x(), |layout| { layout.leaf("visible"); }); }); }); }); - layout.compute([20, 5]); - let mut iter = layout.iter().map(|e| (*e.data, e.pos, e.size)); + let computed = layout.compute([20, 5]); + let mut iter = computed.iter().map(|e| (payload(e.data), e.pos, e.size)); assert_eq!(Some(("visible", [0, 0], [7, 1])), iter.next()); assert_eq!(None, iter.next()); @@ -903,13 +990,13 @@ mod tests { #[test] fn gitu_mockup() { - let mut layout = LayoutTree::new(); + let mut layout = TestTree::new(); - layout.vertical(None, opts(), |layout| { - layout.vertical(None, opts().fill_xy(), |layout| { + layout.col(opts(), |layout| { + layout.col(opts().fill_xy(), |layout| { // Screen - layout.vertical(None, opts().fill_x(), |layout| { - layout.horizontal(Some(""), opts().fill_x(), |layout| { + layout.col(opts().fill_x(), |layout| { + layout.row_with("", opts().fill_x(), |layout| { layout.leaf("On branch master"); }); layout.leaf("Your branch is up to date with 'origin/master'"); @@ -932,12 +1019,12 @@ mod tests { ); }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { // Menu layout.leaf("───────────────────────────────────────────────────────────────"); - layout.horizontal(None, opts().gap(2), |layout| { - layout.vertical(None, opts(), |layout| { + layout.row(opts().gap(2), |layout| { + layout.col(opts(), |layout| { layout.leaf("Help"); layout.leaf("Y Show Refs"); layout.leaf(" Toggle section"); @@ -953,7 +1040,7 @@ mod tests { layout.leaf("g+r Refresh"); layout.leaf("q/ Quit/Close"); }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("Submenu"); layout.leaf("b Branch"); layout.leaf("c Commit"); @@ -969,7 +1056,7 @@ mod tests { layout.leaf("z Stash"); layout.leaf(""); }); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout.leaf("@@ -271,7 +271,7"); layout.leaf("s Stage"); layout.leaf("u Unstage"); @@ -989,7 +1076,6 @@ mod tests { }); }); - layout.compute([80, 25]); - insta::assert_snapshot!(render_to_string(layout)); + insta::assert_snapshot!(render_to_string(layout.compute([80, 25]))); } } diff --git a/src/ui/layout/node.rs b/src/ui/layout/node.rs index 72e5c7c627..bce8420307 100644 --- a/src/ui/layout/node.rs +++ b/src/ui/layout/node.rs @@ -9,12 +9,10 @@ pub fn opts() -> Opts { #[derive(Debug, Copy, Clone)] pub struct Opts { - /// Layout direction for children of this node. - pub(crate) dir: Direction, - pub(crate) fill: Vec2, - /// The space between each direct child of this node. + pub(crate) fill: [bool; 2], pub(crate) gap: U, pub(crate) pad: U, + pub(crate) wrap: Option, } impl Default for Opts { @@ -26,33 +24,29 @@ impl Default for Opts { impl Opts { pub fn new() -> Self { Self { - dir: Direction::Horizontal, - fill: Vec2::zero(), + fill: [false, false], gap: U::ZERO, pad: U::ZERO, + wrap: None, } } pub fn fill_x(self) -> Self { Self { - fill: Vec2(U::ONE, U::ZERO), + fill: [true, self.fill[1]], ..self } } - #[allow(dead_code)] pub fn fill_y(self) -> Self { Self { - fill: Vec2(U::ZERO, U::ONE), + fill: [self.fill[0], true], ..self } } pub fn fill_xy(self) -> Self { - Self { - fill: Vec2::one(), - ..self - } + self.fill_x().fill_y() } pub fn gap(self, gap: U) -> Self { @@ -63,58 +57,108 @@ impl Opts { Self { pad, ..self } } - pub(crate) fn is_main_fill(&self, parent: &Self) -> bool { - self.fill * parent.dir.axis() != Vec2::zero() + /// Lets a child that doesn't fit start a new line, rather than being cut + /// off at the edge. + #[allow(dead_code)] + pub fn wrap(self) -> Self { + Self { + wrap: Some(true), + ..self + } + } + + #[allow(dead_code)] + pub fn no_wrap(self) -> Self { + Self { + wrap: Some(false), + ..self + } + } + + pub(crate) fn is_main_fill(&self, parent_dir: Direction) -> bool { + self.fills(parent_dir) } - pub(crate) fn is_cross_fill(&self, parent: &Self) -> bool { - self.fill * parent.dir.axis().flip() != Vec2::zero() + pub(crate) fn is_cross_fill(&self, parent_dir: Direction) -> bool { + self.fills(parent_dir.flip()) } + + fn fills(&self, dir: Direction) -> bool { + match dir { + Direction::Horizontal => self.fill[0], + Direction::Vertical => self.fill[1], + } + } + + /// `ONE` on each axis this node fills and `ZERO` elsewhere, to mask a + /// `Vec2` down to just the filling axes. + pub(crate) fn fill_mask(&self) -> Vec2 { + let bit = |fills| if fills { U::ONE } else { U::ZERO }; + + Vec2(bit(self.fill[0]), bit(self.fill[1])) + } +} + +#[derive(Debug)] +pub(crate) enum NodeData { + Leaf(L), + Container(Option), } #[derive(Debug)] -pub(crate) struct Node { - pub(crate) data: Option, - /// layout options +pub(crate) struct Node { + pub(crate) data: NodeData, + pub(crate) dir: Direction, pub(crate) opts: Opts, /// space actually occupied by this node, updated as nodes are added pub(crate) size: Vec2, - /// Intrinsic (content) size of this subtree, ignoring fill shrinking. - /// A fill node may shrink below this, but an ancestor that does *not* fill - /// a given axis still needs the content extent to size itself on that axis. - pub(crate) content: Vec2, + /// Extent this node's children came to in the fit pass, with any filling + /// descendant shrunk away. + pub(crate) fitted: Vec2, /// Offset from parent's top-left corner, updated as nodes are added. /// This will remain `None` if there's no valid position for the element. pub(crate) pos: Option>, } +impl Node { + pub(crate) fn is_wrapping(&self) -> bool { + self.opts + .wrap + .unwrap_or(matches!(self.dir, Direction::Horizontal)) + } +} + #[cfg(test)] mod tests { #[test] fn is_axis_fill() { - let horizontal = super::Opts { - dir: super::Direction::Horizontal, - ..Default::default() - }; - let vertical = super::Opts { - dir: super::Direction::Vertical, - ..Default::default() - }; - - assert!(super::opts::().fill_x().is_main_fill(&horizontal)); - assert!(super::opts::().fill_y().is_main_fill(&vertical)); - assert!(super::opts::().fill_xy().is_main_fill(&horizontal)); - assert!(super::opts::().fill_xy().is_main_fill(&vertical)); - - assert!(!super::opts::().fill_x().is_main_fill(&vertical)); - assert!(!super::opts::().fill_y().is_main_fill(&horizontal)); - assert!(!super::opts::().fill_x().is_cross_fill(&horizontal)); - assert!(!super::opts::().fill_y().is_cross_fill(&vertical)); - - assert!(!super::opts::().is_main_fill(&horizontal)); - assert!(!super::opts::().is_main_fill(&vertical)); - assert!(!super::opts::().is_cross_fill(&horizontal)); - assert!(!super::opts::().is_cross_fill(&vertical)); + let horizontal = super::Direction::Horizontal; + let vertical = super::Direction::Vertical; + + assert!(super::opts::().fill_x().is_main_fill(horizontal)); + assert!(super::opts::().fill_y().is_main_fill(vertical)); + assert!(super::opts::().fill_xy().is_main_fill(horizontal)); + assert!(super::opts::().fill_xy().is_main_fill(vertical)); + + assert!(!super::opts::().fill_x().is_main_fill(vertical)); + assert!(!super::opts::().fill_y().is_main_fill(horizontal)); + assert!(!super::opts::().fill_x().is_cross_fill(horizontal)); + assert!(!super::opts::().fill_y().is_cross_fill(vertical)); + + assert!(!super::opts::().is_main_fill(horizontal)); + assert!(!super::opts::().is_main_fill(vertical)); + assert!(!super::opts::().is_cross_fill(horizontal)); + assert!(!super::opts::().is_cross_fill(vertical)); + } + + #[test] + fn filling_one_axis_leaves_the_other_alone() { + assert_eq!([true, true], super::opts::().fill_x().fill_y().fill); + assert_eq!([true, true], super::opts::().fill_y().fill_x().fill); + assert_eq!( + super::opts::().fill_xy().fill, + super::opts::().fill_x().fill_y().fill + ); } } diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__top_level_containers_stack_downwards.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__top_level_containers_stack_downwards.snap new file mode 100644 index 0000000000..242b4e67b7 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__top_level_containers_stack_downwards.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +aaa +bbb diff --git a/src/ui/layout/vec2.rs b/src/ui/layout/vec2.rs index 9aa6e927b7..b3d6677cab 100644 --- a/src/ui/layout/vec2.rs +++ b/src/ui/layout/vec2.rs @@ -1,19 +1,14 @@ -use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Rem, RemAssign, Sub, SubAssign}; +use std::ops::{Add, AddAssign, Div, Mul, Sub}; /// The unit a layout is measured in, e.g. terminal cells or pixels. -/// -/// `PartialOrd` rather than `Ord` so that floats qualify; `From` is what -/// lets the engine turn a child count into a unit when distributing fill space. pub trait Scalar: Copy + PartialOrd + std::fmt::Debug - + From + Add + Sub + Mul + Div - + Rem { const ZERO: Self; const ONE: Self; @@ -101,44 +96,12 @@ impl Div for Vec2 { } } -impl Rem for Vec2 { - type Output = Self; - - fn rem(self, rhs: Self) -> Self::Output { - Self(self.0 % rhs.0, self.1 % rhs.1) - } -} - impl AddAssign for Vec2 { fn add_assign(&mut self, rhs: Self) { *self = *self + rhs; } } -impl SubAssign for Vec2 { - fn sub_assign(&mut self, rhs: Self) { - *self = *self - rhs; - } -} - -impl MulAssign for Vec2 { - fn mul_assign(&mut self, rhs: Self) { - *self = *self * rhs; - } -} - -impl DivAssign for Vec2 { - fn div_assign(&mut self, rhs: Self) { - *self = *self / rhs; - } -} - -impl RemAssign for Vec2 { - fn rem_assign(&mut self, rhs: Self) { - *self = *self % rhs; - } -} - impl Vec2 { pub(crate) fn zero() -> Self { Self(U::ZERO, U::ZERO) diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 24176f9084..442f70958e 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -60,13 +60,13 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: let separator_style = Style::from(&style.separator); - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { ui::repeat_chars(layout, width, ui::DASHES, separator_style); - layout.horizontal(None, opts().gap(3).pad(1), |layout| { + layout.row(opts().gap(3).pad(1), |layout| { // Column 1: Main menu commands if !non_menu_binds.is_empty() { - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout_line( layout, pending.menu.to_string().into(), @@ -93,7 +93,7 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: // Column 2: Submenus if !menu_binds.is_empty() { - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { layout_line(layout, "Submenu".into(), Style::from(&style.menu.heading)); layout_keybinds_table( @@ -117,9 +117,9 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: } // Column 3: Target commands and arguments - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { if !target_binds.is_empty() { - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout_item(layout, item, config, Style::new()); }); @@ -179,15 +179,15 @@ fn layout_keybinds_table<'a>( .unwrap_or(0) + 1; - layout.vertical(None, opts(), |layout| { + layout.col(opts(), |layout| { for (key, value) in rows { let padding = max_width - UnicodeWidthStr::width(key.as_ref()); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout_line(layout, key, key_style); repeat_chars(layout, padding, SPACES, Style::new()); - layout.horizontal(None, opts(), |layout| match value { + layout.row(opts(), |layout| match value { MenuValue::Text(text) => layout_span(layout, (text, Style::new())), MenuValue::Arg(arg) => { layout_span(layout, (arg.display.into(), Style::new())); diff --git a/src/ui/picker.rs b/src/ui/picker.rs index 6319bd684c..eab5708d4c 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -23,7 +23,7 @@ pub(crate) fn layout_picker<'a>( let prompt_style: Style = (&config.style.picker.prompt).into(); let info_style: Style = (&config.style.picker.info).into(); - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { let status_text = format!(" {}/{} ", state.filtered_count(), state.total_items()); layout_span(layout, (status_text.into(), info_style)); @@ -57,7 +57,7 @@ pub(crate) fn layout_picker<'a>( Style::new() }; - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { // Selection indicator (cursor bar like status screen) if is_selected { let cursor_style: Style = (&config.style.cursor).into(); @@ -79,7 +79,7 @@ pub(crate) fn layout_picker<'a>( // Fill remaining rows with empty lines to maintain fixed height for _ in rendered_count..MAX_ITEMS_DISPLAY { - layout.horizontal(None, opts(), |layout| { + layout.row(opts(), |layout| { layout_span(layout, (" ".into(), Style::new())); }); } @@ -170,16 +170,20 @@ mod tests { /// Render the picker layout to a string for testing purposes. /// Note: ASCII only — does not support Unicode beyond single-byte chars. - fn render_to_string(layout: UiTree, width: usize, height: usize) -> String { + fn render_to_string( + computed: crate::ui::layout::Computed<'_, crate::ui::Span<'_>, Style>, + width: usize, + height: usize, + ) -> String { let mut grid = vec![' '; height * width]; - for item in layout.iter() { + for item in computed.iter() { let x0 = item.pos[0] as usize; let y0 = item.pos[1] as usize; let item_width = item.size[0] as usize; let text = match item.data { - crate::ui::UiItem::Span(cow, _) => cow, - crate::ui::UiItem::Style(_) => "", + crate::ui::layout::Payload::Leaf(crate::ui::Span(cow, _)) => cow.as_ref(), + crate::ui::layout::Payload::Container(_style) => "", }; for (i, c) in text.chars().take(item_width).enumerate() { @@ -201,12 +205,10 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -229,12 +231,10 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -249,12 +249,10 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -278,12 +276,10 @@ mod tests { } let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -307,12 +303,10 @@ mod tests { } let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -338,12 +332,10 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -363,12 +355,10 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -391,12 +381,10 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 40); }); - layout.compute([40, 15]); - - insta::assert_snapshot!(render_to_string(layout, 40, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([40, 15]), 40, 15)); } #[test] @@ -406,11 +394,9 @@ mod tests { let config = test_config(); let mut layout = LayoutTree::new(); - layout.vertical(None, crate::ui::layout::opts(), |layout| { + layout.col(crate::ui::layout::opts(), |layout| { layout_picker(layout, &state, &config, 20); }); - layout.compute([20, 15]); - - insta::assert_snapshot!(render_to_string(layout, 20, 15)); + insta::assert_snapshot!(render_to_string(layout.compute([20, 15]), 20, 15)); } }