From de97a1f1da4fd146034f7c8f20264f4d558cc1a0 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Sat, 13 Jan 2024 04:51:41 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20flex=20to=20layout=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new way to space elements in a `Layout`. Loosely based on [flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/), this PR adds a `Flex` enum with the following variants: - Start - Center - End - SpaceAround - SpaceBetween image It also adds two more variants, to make this backward compatible and to make it replace `SegmentSize`: - StretchLast (default in the `Flex` enum, also behavior matches old default `SegmentSize::LastTakesRemainder`) - Stretch (behavior matches `SegmentSize::EvenDistribution`) The `Start` variant from above matches `SegmentSize::None`. This allows `Flex` to be a complete replacement for `SegmentSize`, hence this PR also deprecates the `segment_size` constructor on `Layout`. `SegmentSize` is still used in `Table` but under the hood `segment_size` maps to `Flex` with all tests passing unchanged. I also put together a simple example for `Flex` layouts so that I could test it visually, shared below: https://github.com/ratatui-org/ratatui/assets/1813121/c8716c59-493f-4631-add5-feecf4bd4e06 --- Cargo.toml | 5 + examples/flex.rs | 252 +++++++++++++++++++++++++ src/layout.rs | 2 + src/layout/flex.rs | 190 +++++++++++++++++++ src/layout/layout.rs | 378 +++++++++++++++++++++++++++++++------ src/layout/segment_size.rs | 1 + src/widgets/table/table.rs | 1 + 7 files changed, 768 insertions(+), 61 deletions(-) create mode 100644 examples/flex.rs create mode 100644 src/layout/flex.rs diff --git a/Cargo.toml b/Cargo.toml index 2f191adc..3879fde7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,6 +214,11 @@ name = "constraints" required-features = ["crossterm"] doc-scrape-examples = false +[[example]] +name = "flex" +required-features = ["crossterm"] +doc-scrape-examples = false + [[example]] name = "list" required-features = ["crossterm"] diff --git a/examples/flex.rs b/examples/flex.rs new file mode 100644 index 00000000..233acf56 --- /dev/null +++ b/examples/flex.rs @@ -0,0 +1,252 @@ +use std::{error::Error, io}; + +use crossterm::{ + event::{self, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use itertools::Itertools; +use ratatui::{ + layout::{Constraint::*, Flex}, + prelude::*, + widgets::*, +}; + +fn main() -> Result<(), Box> { + // setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // create app and run it + let res = run_app(&mut terminal); + + // restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + Ok(()) +} + +fn run_app(terminal: &mut Terminal) -> io::Result<()> { + let mut selection = ExampleSelection::Stretch; + loop { + terminal.draw(|f| f.render_widget(selection, f.size()))?; + + if let Event::Key(key) = event::read()? { + use KeyCode::*; + match key.code { + Char('q') => break Ok(()), + Char('j') | Char('l') | Down | Right => { + selection = selection.next(); + } + Char('k') | Char('h') | Up | Left => { + selection = selection.previous(); + } + _ => (), + } + } + } +} + +#[derive(Debug, Copy, Clone)] +enum ExampleSelection { + Stretch, + StretchLast, + Start, + Center, + End, + SpaceAround, + SpaceBetween, +} + +impl ExampleSelection { + fn previous(&self) -> Self { + use ExampleSelection::*; + match *self { + Stretch => Stretch, + StretchLast => Stretch, + Start => StretchLast, + Center => Start, + End => Center, + SpaceAround => End, + SpaceBetween => SpaceAround, + } + } + + fn next(&self) -> Self { + use ExampleSelection::*; + match *self { + Stretch => StretchLast, + StretchLast => Start, + Start => Center, + Center => End, + End => SpaceAround, + SpaceAround => SpaceBetween, + SpaceBetween => SpaceBetween, + } + } + + fn selected(&self) -> usize { + use ExampleSelection::*; + match self { + Stretch => 0, + StretchLast => 1, + Start => 2, + Center => 3, + End => 4, + SpaceAround => 5, + SpaceBetween => 6, + } + } +} + +impl Widget for ExampleSelection { + fn render(self, area: Rect, buf: &mut Buffer) { + let [tabs, area] = area.split(&Layout::vertical([Fixed(3), Proportional(0)])); + + self.render_tabs(tabs, buf); + + match self { + ExampleSelection::Stretch => self.render_example(area, buf, Flex::Stretch), + ExampleSelection::StretchLast => self.render_example(area, buf, Flex::StretchLast), + ExampleSelection::Start => self.render_example(area, buf, Flex::Start), + ExampleSelection::Center => self.render_example(area, buf, Flex::Center), + ExampleSelection::End => self.render_example(area, buf, Flex::End), + ExampleSelection::SpaceAround => self.render_example(area, buf, Flex::SpaceAround), + ExampleSelection::SpaceBetween => self.render_example(area, buf, Flex::SpaceBetween), + } + } +} + +impl ExampleSelection { + fn render_tabs(&self, area: Rect, buf: &mut Buffer) { + Tabs::new( + [ + ExampleSelection::Stretch, + ExampleSelection::StretchLast, + ExampleSelection::Start, + ExampleSelection::Center, + ExampleSelection::End, + ExampleSelection::SpaceAround, + ExampleSelection::SpaceBetween, + ] + .iter() + .map(|e| format!("{:?}", e)), + ) + .block(Block::bordered().title("Flex Layouts")) + .highlight_style(Style::default().yellow()) + .select(self.selected()) + .padding(" ", " ") + .render(area, buf); + } + + fn render_example(&self, area: Rect, buf: &mut Buffer, flex: Flex) { + let [example1, example2, example3, example4, example5, example6, _] = + area.split(&Layout::vertical([Fixed(8); 7])); + + Example::new([Length(20), Length(10)]) + .flex(flex) + .render(example1, buf); + Example::new([Length(20), Fixed(10)]) + .flex(flex) + .render(example2, buf); + Example::new([Proportional(1), Proportional(1), Length(40), Fixed(20)]) + .flex(flex) + .render(example3, buf); + Example::new([Min(20), Length(40), Fixed(20)]) + .flex(flex) + .render(example4, buf); + Example::new([Min(20), Proportional(0), Length(40), Fixed(20)]) + .flex(flex) + .render(example5, buf); + Example::new([ + Min(20), + Proportional(0), + Percentage(10), + Length(40), + Fixed(20), + ]) + .flex(flex) + .render(example6, buf); + } +} + +struct Example { + constraints: Vec, + flex: Flex, +} + +impl Example { + fn new(constraints: C) -> Self + where + C: Into>, + { + Self { + constraints: constraints.into(), + flex: Flex::default(), + } + } + + fn flex(mut self, flex: Flex) -> Self { + self.flex = flex; + self + } +} + +impl Widget for Example { + fn render(self, area: Rect, buf: &mut Buffer) { + let [title, legend, area] = area.split(&Layout::vertical([Ratio(1, 3); 3])); + let blocks = Layout::horizontal(&self.constraints) + .flex(self.flex) + .split(area); + + self.heading().render(title, buf); + + self.legend(legend.width as usize).render(legend, buf); + + for (i, (block, _constraint)) in blocks.iter().zip(&self.constraints).enumerate() { + let text = format!("{} px", block.width); + let fg = Color::Indexed(i as u8 + 1); + self.illustration(text, fg).render(*block, buf); + } + } +} + +impl Example { + fn heading(&self) -> Paragraph { + // Renders the following + // + // Fixed(40), Proportional(0) + let spans = self.constraints.iter().enumerate().map(|(i, c)| { + let color = Color::Indexed(i as u8 + 1); + Span::styled(format!("{:?}", c), color) + }); + let heading = + Line::from(Itertools::intersperse(spans, Span::raw(", ")).collect::>()); + Paragraph::new(heading).block(Block::default().padding(Padding::vertical(1))) + } + + fn legend(&self, width: usize) -> Paragraph { + // a bar like `<----- 80 px ----->` + let width_label = format!("{} px", width); + let width_bar = format!( + "<{width_label:-^width$}>", + width = width - width_label.len() / 2 + ); + Paragraph::new(width_bar.dark_gray()).alignment(Alignment::Center) + } + + fn illustration(&self, text: String, fg: Color) -> Paragraph { + Paragraph::new(text) + .alignment(Alignment::Center) + .block(Block::bordered().style(Style::default().fg(fg))) + } +} diff --git a/src/layout.rs b/src/layout.rs index 48f67882..0cf6b6e2 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -2,6 +2,7 @@ mod alignment; mod constraint; mod corner; mod direction; +mod flex; #[allow(clippy::module_inception)] mod layout; mod margin; @@ -13,6 +14,7 @@ pub use alignment::Alignment; pub use constraint::Constraint; pub use corner::Corner; pub use direction::Direction; +pub use flex::Flex; pub use layout::Layout; pub use margin::Margin; pub use rect::*; diff --git a/src/layout/flex.rs b/src/layout/flex.rs new file mode 100644 index 00000000..78f036c2 --- /dev/null +++ b/src/layout/flex.rs @@ -0,0 +1,190 @@ +use strum::{Display, EnumString}; + +/// Defines the options for layout flex justify content in a container. +/// +/// This enumeration controls the distribution of space when layout constraints are met. +/// +/// - `StretchLast`: Fills the available space within the container, putting excess space into the +/// last element. +/// - `Stretch`: Always fills the available space within the container. +/// - `Start`: Aligns items to the start of the container. +/// - `End`: Aligns items to the end of the container. +/// - `Center`: Centers items within the container. +/// - `SpaceBetween`: Adds excess space between each element. +/// - `SpaceAround`: Adds excess space around each element. +#[derive(Copy, Debug, Default, Display, EnumString, Clone, Eq, PartialEq, Hash)] +pub enum Flex { + /// Fills the available space within the container, putting excess space into the last element. + /// This matches the default behavior of ratatui and tui applications without [`Flex`] + /// + /// # Examples + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌──────────────────────────────────────────────────────────┐ + /// │ 20 px ││ 60 px │ + /// └──────────────────┘└──────────────────────────────────────────────────────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌────────────────────────────────────────────────────────────────────┐┌────────┐ + /// │ 70 px ││ 10 px │ + /// └────────────────────────────────────────────────────────────────────┘└────────┘ + /// ``` + #[default] + StretchLast, + + /// Always fills the available space within the container. + /// + /// # Examples + /// + /// Length(40), Length(20) + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────────────────────────┐┌──────────────────────────────────────┐ + /// │ 40 px ││ 40 px │ + /// └──────────────────────────────────────┘└──────────────────────────────────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌────────────────────────────────────────────────────────────────────┐┌────────┐ + /// │ 70 px ││ 10 px │ + /// └────────────────────────────────────────────────────────────────────┘└────────┘ + /// ``` + Stretch, + + /// Aligns items to the start of the container. + /// + /// # Examples + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌────────┐ + /// │ 20 px ││ 10 px │ + /// └──────────────────┘└────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌────────┐ + /// │ 20 px ││ 10 px │ + /// └──────────────────┘└────────┘ + /// ``` + Start, + + /// Aligns items to the end of the container. + /// + /// # Examples + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌────────┐ + /// │ 20 px ││ 10 px │ + /// └──────────────────┘└────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌────────┐ + /// │ 20 px ││ 10 px │ + /// └──────────────────┘└────────┘ + /// ``` + End, + + /// Centers items within the container. + /// + /// # Examples + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌────────┐ + /// │ 20 px ││ 10 px │ + /// └──────────────────┘└────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐┌────────┐ + /// │ 20 px ││ 10 px │ + /// └──────────────────┘└────────┘ + /// ``` + Center, + + /// Adds excess space between each element. + /// + /// # Examples + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐ ┌────────┐ + /// │ 20 px │ │ 10 px │ + /// └──────────────────┘ └────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐ ┌────────┐ + /// │ 20 px │ │ 10 px │ + /// └──────────────────┘ └────────┘ + /// ``` + SpaceBetween, + + /// Adds excess space around each element. + /// + /// # Examples + /// + /// ```plain + /// + /// Length(20), Length(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐ ┌────────┐ + /// │ 20 px │ │ 10 px │ + /// └──────────────────┘ └────────┘ + /// + /// Length(20), Fixed(10) + /// + /// <------------------------------------80 px-------------------------------------> + /// + /// ┌──────────────────┐ ┌────────┐ + /// │ 20 px │ │ 10 px │ + /// └──────────────────┘ └────────┘ + /// ``` + SpaceAround, +} +#[cfg(test)] +mod tests {} diff --git a/src/layout/layout.rs b/src/layout/layout.rs index 9ce56877..b2e1f720 100644 --- a/src/layout/layout.rs +++ b/src/layout/layout.rs @@ -8,7 +8,7 @@ use cassowary::{ use itertools::Itertools; use lru::LruCache; -use super::SegmentSize; +use super::{Flex, SegmentSize}; use crate::prelude::*; type Cache = LruCache<(Rect, Layout), Rc<[Rect]>>; @@ -60,8 +60,7 @@ thread_local! { /// - [`Layout::margin`]: set the margin of the layout /// - [`Layout::horizontal_margin`]: set the horizontal margin of the layout /// - [`Layout::vertical_margin`]: set the vertical margin of the layout -/// - [`Layout::segment_size`]: set the way the space is distributed when the constraints are -/// satisfied +/// - [`Layout::flex`]: set the way the space is distributed when the constraints are satisfied /// /// # Example /// @@ -91,8 +90,7 @@ pub struct Layout { direction: Direction, constraints: Vec, margin: Margin, - /// option for segment size preferences - segment_size: SegmentSize, + flex: Flex, } /// A container used by the solver inside split @@ -114,7 +112,7 @@ impl Layout { /// Default values for the other fields are: /// /// - `margin`: 0, 0 - /// - `segment_size`: SegmentSize::LastTakesRemainder + /// - `flex`: Flex::Fill /// /// # Examples /// @@ -141,7 +139,7 @@ impl Layout { direction, margin: Margin::new(0, 0), constraints: constraints.into_iter().map(Into::into).collect(), - segment_size: SegmentSize::LastTakesRemainder, + flex: Flex::default(), } } @@ -343,6 +341,12 @@ impl Layout { self } + /// Sets flex options for justify content + pub const fn flex(mut self, flex: Flex) -> Layout { + self.flex = flex; + self + } + /// Set whether chunks should be of equal size. /// /// This determines how the space is distributed when the constraints are satisfied. By default, @@ -350,19 +354,25 @@ impl Layout { /// equal chunks or to not distribute extra space at all (which is the default used for laying /// out the columns for [`Table`] widgets). /// - /// Note: If you're using this feature please help us come up with a good name. See [Issue - /// #536](https://github.com/ratatui-org/ratatui/issues/536) for more information. + /// This function exists for backwards compatibility reasons. Use [`Layout::flex`] instead. /// - /// [`Table`]: crate::widgets::Table + /// - `Flex::StretchLast` does now what `SegmentSize::LastTakesRemainder` did (default). + /// - `Flex::Stretch` does now what `SegmentSize::EvenDistribution` did. + /// - `Flex::Start` does now what `SegmentSize::None` did. #[stability::unstable( feature = "segment-size", reason = "The name for this feature is not final and may change in the future", issue = "https://github.com/ratatui-org/ratatui/issues/536" )] #[must_use = "method moves the value of self and returns the modified value"] - pub const fn segment_size(mut self, segment_size: SegmentSize) -> Layout { - self.segment_size = segment_size; - self + #[deprecated(since = "0.26.0", note = "You should use `Layout::flex` instead.")] + pub const fn segment_size(self, segment_size: SegmentSize) -> Layout { + let flex = match segment_size { + SegmentSize::None => Flex::Start, + SegmentSize::LastTakesRemainder => Flex::StretchLast, + SegmentSize::EvenDistribution => Flex::Stretch, + }; + self.flex(flex) } /// Wrapper function around the cassowary-rs solver to be able to split a given area into @@ -426,34 +436,168 @@ impl Layout { // create an element for each constraint that needs to be applied. Each element defines the // variables that will be used to compute the layout. - let elements = layout + let elements: Vec = layout .constraints .iter() - .map(|_| Element::new()) - .collect::>(); + .map(|_| Element::constrain(&mut solver, (area_start, area_end))) + .try_collect()?; - // ensure that all the elements are inside the area - for element in &elements { - solver.add_constraints(&[ - element.start | GE(REQUIRED) | area_start, - element.end | LE(REQUIRED) | area_end, - element.start | LE(REQUIRED) | element.end, - ])?; - } - // ensure there are no gaps between the elements - for pair in elements.windows(2) { - solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; - } - // ensure the first element touches the left/top edge of the area - if let Some(first) = elements.first() { - solver.add_constraint(first.start | EQ(REQUIRED) | area_start)?; - } - if layout.segment_size != SegmentSize::None { - // ensure the last element touches the right/bottom edge of the area - if let Some(last) = elements.last() { - solver.add_constraint(last.end | EQ(REQUIRED) | area_end)?; + match layout.flex { + Flex::SpaceBetween => { + let spacers: Vec = std::iter::repeat_with(|| { + Element::constrain(&mut solver, (area_start, area_end)) + }) + .take(elements.len().saturating_sub(1)) // one less than the number of elements + .try_collect()?; + // spacers growing should be the lowest priority + for spacer in spacers.iter() { + solver.add_constraint(spacer.size() | EQ(WEAK) | area_size)?; + } + // Spacers should all be similar in size + // these constraints should not be stronger than existing constraints + // but if they are weaker `Min` and `Max` won't be pushed to their desired values + // I found using `STRONG` gives the most desirable behavior + for (left, right) in spacers.iter().tuple_combinations() { + solver.add_constraint(left.size() | EQ(STRONG) | right.size())?; + } + // interleave elements and spacers + // for `SpaceBetween` we want the following + // `[element, spacer, element, spacer, ..., element]` + // this is why we use one less spacer than elements + for pair in Itertools::interleave(elements.iter(), spacers.iter()) + .collect::>() + .windows(2) + { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } + } + Flex::SpaceAround => { + let spacers: Vec = std::iter::repeat_with(|| { + Element::constrain(&mut solver, (area_start, area_end)) + }) + .take(elements.len().saturating_add(1)) // one more than number of elements + .try_collect()?; + // spacers growing should be the lowest priority + for spacer in spacers.iter() { + solver.add_constraint(spacer.size() | EQ(WEAK) | area_size)?; + } + // Spacers should all be similar in size + // these constraints should not be stronger than existing constraints + // but if they are weaker `Min` and `Max` won't be pushed to their desired values + // I found using `STRONG` gives the most desirable behavior + for (left, right) in spacers.iter().tuple_combinations() { + solver.add_constraint(left.size() | EQ(STRONG) | right.size())?; + } + // interleave spacers and elements + // for `SpaceAround` we want the following + // `[spacer, element, spacer, element, ..., element, spacer]` + // this is why we use one spacer than elements + for pair in Itertools::interleave(spacers.iter(), elements.iter()) + .collect::>() + .windows(2) + { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } + } + Flex::StretchLast => { + // this is the default behavior + // within reason, cassowary tends to put excess into the last constraint + if let Some(first) = elements.first() { + solver.add_constraint(first.start | EQ(REQUIRED) | area_start)?; + } + if let Some(last) = elements.last() { + solver.add_constraint(last.end | EQ(REQUIRED) | area_end)?; + } + // ensure there are no gaps between the elements + for pair in elements.windows(2) { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } + } + Flex::Stretch => { + if let Some(first) = elements.first() { + solver.add_constraint(first.start | EQ(REQUIRED) | area_start)?; + } + if let Some(last) = elements.last() { + solver.add_constraint(last.end | EQ(REQUIRED) | area_end)?; + } + // prefer equal elements if other constraints are all satisfied + for (left, right) in elements.iter().tuple_combinations() { + solver.add_constraint(left.size() | EQ(WEAK) | right.size())?; + } + // ensure there are no gaps between the elements + for pair in elements.windows(2) { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } + } + Flex::Center => { + // for center, we add two flex elements, one at the beginning and one at the end. + // this frees up inner constraints to be their true size + let flex_start_element = Element::constrain(&mut solver, (area_start, area_end))?; + let flex_end_element = Element::constrain(&mut solver, (area_start, area_end))?; + // the start flex element must be before the users constraint + if let Some(first) = elements.first() { + solver.add_constraints(&[ + flex_start_element.start | EQ(REQUIRED) | area_start, + first.start | EQ(REQUIRED) | flex_start_element.end, + ])?; + } + // the end flex element must be after the users constraint + if let Some(last) = elements.last() { + solver.add_constraints(&[ + last.end | EQ(REQUIRED) | flex_end_element.start, + flex_end_element.end | EQ(REQUIRED) | area_end, + ])?; + } + // finally we ask for a strong preference to make the starting flex and ending flex + // the same size, and this results in the remaining constraints being centered + solver.add_constraint( + flex_start_element.size() | EQ(STRONG) | flex_end_element.size(), + )?; + // ensure there are no gaps between the elements + for pair in elements.windows(2) { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } + } + Flex::Start => { + // for start, we add one flex element one at the end. + // this frees up the end constraints and allows inner constraints to be aligned to + // the start + let flex_end_element = Element::constrain(&mut solver, (area_start, area_end))?; + if let Some(first) = elements.first() { + solver.add_constraint(first.start | EQ(REQUIRED) | area_start)?; + } + if let Some(last) = elements.last() { + solver.add_constraints(&[ + last.end | EQ(REQUIRED) | flex_end_element.start, + flex_end_element.end | EQ(REQUIRED) | area_end, + ])?; + } + // ensure there are no gaps between the elements + for pair in elements.windows(2) { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } + } + Flex::End => { + // for end, we add one flex element one at the start. + // this frees up the start constraints and allows inner constraints to be aligned to + // the end + let flex_start_element = Element::constrain(&mut solver, (area_start, area_end))?; + if let Some(first) = elements.first() { + solver.add_constraints(&[ + flex_start_element.start | EQ(REQUIRED) | area_start, + first.start | EQ(REQUIRED) | flex_start_element.end, + ])?; + } + if let Some(last) = elements.last() { + solver.add_constraint(last.end | EQ(REQUIRED) | area_end)?; + } + // ensure there are no gaps between the elements + for pair in elements.windows(2) { + solver.add_constraint(pair[0].end | EQ(REQUIRED) | pair[1].start)?; + } } } + // apply the constraints for (&constraint, &element) in layout.constraints.iter().zip(elements.iter()) { match constraint { @@ -490,12 +634,14 @@ impl Layout { Constraint::Proportional(_) => { // given no other constraints, this segment will grow as much as possible. // - // in the current implementation, this constraint will not have any effect - // since in every combination of constraints, other constraints governing - // element size will take a higher priority. - // - // this constraint is placed here only for future proofing. - solver.add_constraint(element.size() | EQ(WEAK) | area_size)?; + // We want proportional constraints to behave the same as they do without + // spacers but we also want them to be fill excess space + // before a spacer fills excess space. This means we want + // Proportional to be stronger than a spacer constraint but weaker than all the + // other constraints. + // In my tests, I found choosing an order of magnitude weaker than a `MEDIUM` + // constraint did the trick. + solver.add_constraint(element.size() | EQ(MEDIUM / 10.0) | area_size)?; } } } @@ -554,12 +700,6 @@ impl Layout { )?; } } - // prefer equal chunks if other constraints are all satisfied - if layout.segment_size == SegmentSize::EvenDistribution { - for (left, right) in elements.iter().tuple_combinations() { - solver.add_constraint(left.size() | EQ(WEAK) | right.size())?; - } - } let changes: HashMap = solver.fetch_changes().iter().copied().collect(); @@ -602,13 +742,30 @@ impl Layout { } impl Element { - fn new() -> Element { - Element { + #[allow(dead_code)] + fn new() -> Self { + Self { start: Variable::new(), end: Variable::new(), } } + fn constrain( + solver: &mut Solver, + (area_start, area_end): (f64, f64), + ) -> Result { + let e = Element { + start: Variable::new(), + end: Variable::new(), + }; + solver.add_constraints(&[ + e.start | GE(REQUIRED) | area_start, + e.end | LE(REQUIRED) | area_end, + e.start | LE(REQUIRED) | e.end, + ])?; + Ok(e) + } + fn size(&self) -> Expression { self.end - self.start } @@ -663,7 +820,7 @@ mod tests { direction: Direction::Vertical, margin: Margin::new(0, 0), constraints: vec![], - segment_size: SegmentSize::LastTakesRemainder, + flex: Flex::default(), } ); } @@ -707,7 +864,7 @@ mod tests { direction: Direction::Vertical, margin: Margin::new(0, 0), constraints: vec![Constraint::Min(0)], - segment_size: SegmentSize::LastTakesRemainder, + flex: Flex::default(), } ); } @@ -720,7 +877,7 @@ mod tests { direction: Direction::Horizontal, margin: Margin::new(0, 0), constraints: vec![Constraint::Min(0)], - segment_size: SegmentSize::LastTakesRemainder, + flex: Flex::default(), } ); } @@ -820,24 +977,28 @@ mod tests { } #[test] + fn flex_default() { + assert_eq!(Layout::default().flex, Flex::StretchLast); + } + + #[test] + #[allow(deprecated)] fn segment_size() { assert_eq!( Layout::default() .segment_size(SegmentSize::EvenDistribution) - .segment_size, - SegmentSize::EvenDistribution + .flex, + Flex::Stretch ); assert_eq!( Layout::default() .segment_size(SegmentSize::LastTakesRemainder) - .segment_size, - SegmentSize::LastTakesRemainder + .flex, + Flex::StretchLast ); assert_eq!( - Layout::default() - .segment_size(SegmentSize::None) - .segment_size, - SegmentSize::None + Layout::default().segment_size(SegmentSize::None).flex, + Flex::Start ); } @@ -860,9 +1021,11 @@ mod tests { /// - overflow: constraint is for more than the full space mod split { use pretty_assertions::assert_eq; + use rstest::rstest; use crate::{ assert_buffer_eq, + layout::flex::Flex, prelude::{Constraint::*, *}, widgets::{Paragraph, Widget}, }; @@ -1719,5 +1882,98 @@ mod tests { ])); assert_eq!([a.width, b.width, c.width, d.width], [0, 0, 0, 100]); } + + #[rstest] + #[case::length_stretches_to_end(Constraint::Length(50), Flex::StretchLast, (0, 100))] + #[case::length_stretches(Constraint::Length(50), Flex::Stretch, (0, 100))] + #[case::length_left_justified(Constraint::Length(50), Flex::Start, (0, 50))] + #[case::length_right_justified(Length(50), Flex::End, (50, 50))] + #[case::length_center_justified(Length(50), Flex::Center, (25, 50))] + #[case::fixed_stretches_to_end(Fixed(50), Flex::StretchLast, (0, 100))] + #[case::fixed_left_justified(Fixed(50), Flex::Start, (0, 50))] + #[case::fixed_right_justified(Fixed(50), Flex::End, (50, 50))] + #[case::fixed_center_justified(Fixed(50), Flex::Center, (25, 50))] + #[case::ratio_stretches_to_end(Ratio(1, 2), Flex::StretchLast, (0, 100))] + #[case::ratio_left_justified(Ratio(1, 2), Flex::Start, (0, 50))] + #[case::ratio_right_justified(Ratio(1, 2), Flex::End, (50, 50))] + #[case::ratio_center_justified(Ratio(1, 2), Flex::Center, (25, 50))] + #[case::percent_stretches_to_end(Percentage(50), Flex::StretchLast, (0, 100))] + #[case::percent_left_justified(Percentage(50), Flex::Start, (0, 50))] + #[case::percent_right_justified(Percentage(50), Flex::End, (50, 50))] + #[case::percent_center_justified(Percentage(50), Flex::Center, (25, 50))] + #[case::min_stretches_to_end(Min(50), Flex::StretchLast, (0, 100))] + #[case::min_left_justified(Min(50), Flex::Start, (0, 50))] + #[case::min_right_justified(Min(50), Flex::End, (50, 50))] + #[case::min_center_justified(Min(50), Flex::Center, (25, 50))] + #[case::max_stretches_to_end(Max(50), Flex::StretchLast, (0, 100))] + #[case::max_left_justified(Max(50), Flex::Start, (0, 50))] + #[case::max_right_justified(Max(50), Flex::End, (50, 50))] + #[case::max_center_justified(Max(50), Flex::Center, (25, 50))] + fn flex_one_constraint( + #[case] constraint: Constraint, + #[case] flex: Flex, + #[case] expected_widths: (u16, u16), + ) { + let [a] = Rect::new(0, 0, 100, 1).split(&Layout::horizontal([constraint]).flex(flex)); + assert_eq!((a.x, a.width), expected_widths); + } + + #[rstest] + #[case::length_stretches_to_end([Length(25), Length(25)], Flex::StretchLast, [(0, 25), (25, 75)])] + #[case::splits_equally_to_end([Length(25), Length(25)], Flex::Stretch, [(0, 50), (50, 50)])] + #[case::lengths_justify_to_start([Length(25), Length(25)], Flex::Start, [(0, 25), (25, 25)])] + #[case::length_justifies_to_center([Length(25), Length(25)], Flex::Center, [(25, 25), (50, 25)])] + #[case::length_justifies_to_end([Length(25), Length(25)], Flex::End, [(50, 25), (75, 25)])] + #[case::fixed_stretches_to_end_last([Fixed(25), Fixed(25)], Flex::StretchLast, [(0, 25), (25, 75)])] + #[case::fixed_stretches_to_end([Fixed(25), Fixed(25)], Flex::Stretch, [(0, 50), (50, 50)])] + #[case::fixed_justifies_to_start([Fixed(25), Fixed(25)], Flex::Start, [(0, 25), (25, 25)])] + #[case::fixed_justifies_to_center([Fixed(25), Fixed(25)], Flex::Center, [(25, 25), (50, 25)])] + #[case::fixed_justifies_to_end([Fixed(25), Fixed(25)], Flex::End, [(50, 25), (75, 25)])] + #[case::percentage_stretches_to_end_last([Percentage(25), Percentage(25)], Flex::StretchLast, [(0, 25), (25, 75)])] + #[case::percentage_stretches_to_end([Percentage(25), Percentage(25)], Flex::Stretch, [(0, 50), (50, 50)])] + #[case::percentage_justifies_to_start([Percentage(25), Percentage(25)], Flex::Start, [(0, 25), (25, 25)])] + #[case::percentage_justifies_to_center([Percentage(25), Percentage(25)], Flex::Center, [(25, 25), (50, 25)])] + #[case::percentage_justifies_to_end([Percentage(25), Percentage(25)], Flex::End, [(50, 25), (75, 25)])] + #[case::min_stretches_to_end([Min(25), Min(25)], Flex::StretchLast, [(0, 25), (25, 75)])] + #[case::min_stretches_to_end([Min(25), Min(25)], Flex::Stretch, [(0, 50), (50, 50)])] + #[case::min_justifies_to_start([Min(25), Min(25)], Flex::Start, [(0, 25), (25, 25)])] + #[case::min_justifies_to_center([Min(25), Min(25)], Flex::Center, [(25, 25), (50, 25)])] + #[case::min_justifies_to_end([Min(25), Min(25)], Flex::End, [(50, 25), (75, 25)])] + #[case::length_spaced_between([Length(25), Length(25)], Flex::SpaceBetween, [(0, 25), (75, 25)])] + #[case::length_spaced_around([Length(25), Length(25)], Flex::SpaceAround, [(17, 25), (58, 25)])] + fn flex_two_constraints( + #[case] constraints: [Constraint; 2], + #[case] flex: Flex, + #[case] expected_widths: [(u16, u16); 2], + ) { + let [a, b] = Rect::new(0, 0, 100, 1).split(&Layout::horizontal(constraints).flex(flex)); + assert_eq!([(a.x, a.width), (b.x, b.width)], expected_widths); + } + + #[rstest] + #[case::length_spaced_around([Length(25), Length(25), Length(25)], Flex::SpaceBetween, [(0, 25), (38, 25), (75, 25)])] + fn flex_three_constraints( + #[case] constraints: [Constraint; 3], + #[case] flex: Flex, + #[case] expected_widths: [(u16, u16); 3], + ) { + let [a, b, c] = + Rect::new(0, 0, 100, 1).split(&Layout::horizontal(constraints).flex(flex)); + assert_eq!( + [(a.x, a.width), (b.x, b.width), (c.x, c.width)], + expected_widths + ); + } + + #[test] + fn flex() { + // length should be spaced around + let [a, b, c] = Rect::new(0, 0, 100, 1).split( + &Layout::horizontal([Length(25), Length(25), Length(25)]).flex(Flex::SpaceAround), + ); + assert!(b.x == 37 || b.x == 38); + assert!(b.width == 26 || b.width == 25); + assert_eq!([[a.x, a.width], [c.x, c.width]], [[6, 25], [69, 25]]); + } } } diff --git a/src/layout/segment_size.rs b/src/layout/segment_size.rs index a6a247be..e5c83796 100644 --- a/src/layout/segment_size.rs +++ b/src/layout/segment_size.rs @@ -60,6 +60,7 @@ mod tests { constraints: Vec, target: Rect, ) -> Vec<(u16, u16)> { + #[allow(deprecated)] let layout = Layout::default() .direction(Direction::Horizontal) .constraints(constraints) diff --git a/src/widgets/table/table.rs b/src/widgets/table/table.rs index d560a67d..7f8fd623 100644 --- a/src/widgets/table/table.rs +++ b/src/widgets/table/table.rs @@ -741,6 +741,7 @@ impl Table<'_> { Constraint::Length(self.column_spacing), )) .collect_vec(); + #[allow(deprecated)] let layout = Layout::horizontal(constraints) .segment_size(self.segment_size) .split(Rect::new(0, 0, max_width, 1));