feat: Change priority of constraints and add split_with_spacers (#788)

Follow up to https://github.com/ratatui-org/ratatui/pull/783

This PR introduces different priorities for each kind of constraint.
This PR also adds tests that specifies this behavior. This PR resolves a
number of broken tests.

Fixes https://github.com/ratatui-org/ratatui/issues/827

With this PR, the layout algorithm will do the following in order:

1. Ensure that all the segments are within the user provided area and
ensure that all segments and spacers are aligned next to each other
2. if a user provides a `layout.spacing`, it will enforce it.
3. ensure proportional elements are all proportional to each other
4. if a user provides a `Fixed(v)` constraint, it will enforce it. 
5. `Min` / `Max` binding inequality constraints
6. `Length`
7. `Percentage`
8. `Ratio`
9. collapse `Min` or collapse `Max`
10. grow `Proportional` as much as possible
11. grow spacers as much as possible

This PR also returns the spacer areas as `Rects` to the user. Users can
then draw into the spacers as they see fit (thanks @joshka for the
idea). Here's a screenshot with the modified flex example:

<img width="569" alt="image"
src="https://github.com/ratatui-org/ratatui/assets/1813121/46c8901d-882c-43b0-ba87-b1d455099d8f">

This PR introduces a `strengths` module that has "default" weights that
give stable solutions as well as predictable behavior.
This commit is contained in:
Dheepak Krishnamurthy 2024-01-27 15:35:42 -05:00 committed by GitHub
parent c1ed5c3637
commit be4fdaa0c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1009 additions and 552 deletions

View File

@ -25,6 +25,7 @@ use ratatui::{
layout::{Constraint::*, Flex},
prelude::*,
style::palette::tailwind,
symbols::line,
widgets::{block::Title, *},
};
use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
@ -32,35 +33,56 @@ use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
const EXAMPLE_DATA: &[(&str, &[Constraint])] = &[
(
"Min(u16) takes any excess space when using `Stretch` or `StretchLast`",
&[Fixed(20), Min(20), Max(20)],
&[Fixed(10), Min(10), Max(10), Percentage(10), Ratio(1,10)],
),
(
"Proportional(u16) takes any excess space in all `Flex` layouts",
"Proportional(u16) takes any excess space always",
&[Length(20), Percentage(20), Ratio(1, 5), Proportional(1)],
),
(
"In `StretchLast`, last constraint of lowest priority takes excess space",
"Here's all constraints in one line",
&[Fixed(10), Min(10), Max(10), Percentage(10), Ratio(1,10), Proportional(1)],
),
(
"",
&[Percentage(50), Percentage(25), Ratio(1, 8), Min(10)],
),
(
"In `StretchLast`, the last constraint of lowest priority takes excess space",
&[Length(20), Fixed(20), Percentage(20)],
),
("", &[Fixed(20), Percentage(20), Length(20)]),
("", &[Percentage(20), Length(20), Fixed(20)]),
("", &[Length(20), Length(15)]),
("Spacing has no effect in `SpaceAround` and `SpaceBetween`", &[Proportional(1), Proportional(1)]),
("A lowest priority constraint will be broken before a high priority constraint", &[Ratio(1,4), Percentage(20)]),
("`Length` is higher priority than `Percentage`", &[Percentage(20), Length(10)]),
("`Min/Max` is higher priority than `Length`", &[Length(10), Max(20)]),
("", &[Length(100), Min(20)]),
("`Fixed` is higher priority than `Min/Max`", &[Max(20), Fixed(10)]),
("", &[Min(20), Fixed(90)]),
("Proportional is the lowest priority and will fill any excess space", &[Proportional(1), Ratio(1, 4)]),
("Proportional can be used to scale proportionally with other Proportional blocks", &[Proportional(1), Percentage(20), Proportional(2)]),
("", &[Ratio(1, 3), Percentage(20), Ratio(2, 3)]),
("StretchLast will stretch the last lowest priority constraint\nStretch will only stretch equal weighted constraints", &[Length(20), Length(15)]),
("", &[Percentage(20), Length(15)]),
("`Proportional(u16)` fills up excess space, but is lower priority to spacers.\ni.e. Proportional will only have widths in Flex::Stretch and Flex::StretchLast", &[Proportional(1), Proportional(1)]),
("", &[Length(20), Fixed(20)]),
(
"When not using `Flex::Stretch` or `Flex::StretchLast`,\n`Min(u16)` and `Max(u16)` collapse to their lowest values",
&[Min(20), Max(20)],
),
(
"`SpaceBetween` stretches when there's only one constraint",
"",
&[Max(20)],
),
("", &[Min(20), Max(20), Length(20), Fixed(20)]),
("`Proportional(u16)` always fills up space in every `Flex` layout", &[Proportional(0), Proportional(0)]),
("", &[Proportional(0), Proportional(0)]),
(
"`Proportional(1)` can be to scale with respect to other `Proportional(2)`",
&[Proportional(1), Proportional(2)],
),
(
"",
&[Proportional(1), Min(10), Max(10), Proportional(2)],
),
(
"`Proportional(0)` collapses if there are other non-zero `Proportional(_)`\nconstraints. e.g. `[Proportional(0), Proportional(0), Proportional(1)]`:",
&[
@ -116,12 +138,6 @@ fn main() -> Result<()> {
Layout::init_cache(EXAMPLE_DATA.len() * SelectedTab::iter().len() * 100);
init_error_hooks()?;
let terminal = init_terminal()?;
// Each line in the example is a layout
// so 13 examples * 7 = 91 currently
// Plus additional layout for tabs ...
Layout::init_cache(120);
App::default().run(terminal)?;
restore_terminal()?;
@ -233,11 +249,19 @@ impl Widget for App {
self.tabs().render(tabs, buf);
let scroll_needed = self.render_demo(demo, buf);
let axis_width = if scroll_needed {
axis.width - 1
axis.width.saturating_sub(1)
} else {
axis.width
};
self.axis(axis_width, self.spacing).render(axis, buf);
let spacing = if matches!(
self.selected_tab,
SelectedTab::SpaceBetween | SelectedTab::SpaceAround
) {
0
} else {
self.spacing
};
self.axis(axis_width, spacing).render(axis, buf);
}
}
@ -264,7 +288,7 @@ impl App {
} else {
format!("{} px", width)
};
let bar_width = width - 2; // we want to `<` and `>` at the ends
let bar_width = width.saturating_sub(2); // we want to `<` and `>` at the ends
let width_bar = format!("<{label:-^bar_width$}>");
Paragraph::new(width_bar.dark_gray()).centered()
}
@ -397,10 +421,11 @@ impl Widget for Example {
let title_height = get_description_height(&self.description);
let layout = Layout::vertical([Fixed(title_height), Proportional(0)]);
let [title, illustrations] = area.split(&layout);
let blocks = Layout::horizontal(&self.constraints)
let (blocks, spacers) = Layout::horizontal(&self.constraints)
.flex(self.flex)
.spacing(self.spacing)
.split(illustrations);
.split_with_spacers(illustrations);
if !self.description.is_empty() {
Paragraph::new(
@ -417,10 +442,59 @@ impl Widget for Example {
self.illustration(*constraint, block.width)
.render(*block, buf);
}
for spacer in spacers.iter() {
self.render_spacer(*spacer, buf);
}
}
}
impl Example {
fn render_spacer(&self, spacer: Rect, buf: &mut Buffer) {
if spacer.width > 1 {
let corners_only = symbols::border::Set {
top_left: line::NORMAL.top_left,
top_right: line::NORMAL.top_right,
bottom_left: line::NORMAL.bottom_left,
bottom_right: line::NORMAL.bottom_right,
vertical_left: " ",
vertical_right: " ",
horizontal_top: " ",
horizontal_bottom: " ",
};
Block::bordered()
.border_set(corners_only)
.border_style(Style::reset().dark_gray())
.render(spacer, buf);
} else {
Paragraph::new(Text::from(vec![
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
]))
.style(Style::reset().dark_gray())
.render(spacer, buf);
}
let width = spacer.width;
let label = if width > 4 {
format!("{width} px")
} else if width > 2 {
format!("{width}")
} else {
"".to_string()
};
let text = Text::from(vec![
Line::raw(""),
Line::raw(""),
Line::styled(label, Style::reset().dark_gray()),
]);
Paragraph::new(text)
.style(Style::reset().dark_gray())
.alignment(Alignment::Center)
.render(spacer, buf);
}
fn illustration(&self, constraint: Constraint, width: u16) -> Paragraph {
let main_color = color_for_constraint(constraint);
let fg_color = Color::White;

View File

@ -1,6 +1,7 @@
use std::fmt::{self, Display};
use itertools::Itertools;
use strum::EnumIs;
/// A constraint that defines the size of a layout element.
///
@ -14,9 +15,12 @@ use itertools::Itertools;
/// Constraints are prioritized in the following order:
///
/// 1. [`Constraint::Fixed`]
/// 2. [`Constraint::Min`] / [`Constraint::Max`]
/// 3. [`Constraint::Length`] / [`Constraint::Percentage`] / [`Constraint::Ratio`]
/// 4. [`Constraint::Proportional`]
/// 2. [`Constraint::Min`]
/// 3. [`Constraint::Max`]
/// 4. [`Constraint::Length`]
/// 5. [`Constraint::Percentage`]
/// 6. [`Constraint::Ratio`]
/// 7. [`Constraint::Proportional`]
///
/// # Examples
///
@ -43,8 +47,97 @@ use itertools::Itertools;
/// // Create a layout with proportional sizes for each element
/// let constraints = Constraint::from_proportional_lengths([1, 2, 1]);
/// ```
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, EnumIs)]
pub enum Constraint {
/// Applies a fixed size to the element
///
/// The element size is set to the specified amount.
/// [`Constraint::Fixed`] will take precedence over all other constraints.
///
/// # Examples
///
/// `[Fixed(40), Proportional(1)]`
///
/// ```plain
/// ┌──────────────────────────────────────┐┌────────┐
/// │ 40 px ││ 10 px │
/// └──────────────────────────────────────┘└────────┘
/// ```
///
/// `[Fixed(20), Fixed(20), Proportional(1)]`
///
/// ```plain
/// ┌──────────────────┐┌──────────────────┐┌────────┐
/// │ 20 px ││ 20 px ││ 10 px │
/// └──────────────────┘└──────────────────┘└────────┘
/// ```
Fixed(u16),
/// Applies a minimum size constraint to the element
///
/// The element size is set to at least the specified amount.
///
/// # Examples
///
/// `[Percentage(100), Min(20)]`
///
/// ```plain
/// ┌────────────────────────────┐┌──────────────────┐
/// │ 30 px ││ 20 px │
/// └────────────────────────────┘└──────────────────┘
/// ```
///
/// `[Percentage(100), Min(10)]`
///
/// ```plain
/// ┌──────────────────────────────────────┐┌────────┐
/// │ 40 px ││ 10 px │
/// └──────────────────────────────────────┘└────────┘
/// ```
Min(u16),
/// Applies a maximum size constraint to the element
///
/// The element size is set to at most the specified amount.
///
/// # Examples
///
/// `[Percentage(100), Min(20)]`
///
/// ```plain
/// ┌────────────────────────────┐┌──────────────────┐
/// │ 30 px ││ 20 px │
/// └────────────────────────────┘└──────────────────┘
/// ```
///
/// `[Percentage(100), Min(10)]`
///
/// ```plain
/// ┌──────────────────────────────────────┐┌────────┐
/// │ 40 px ││ 10 px │
/// └──────────────────────────────────────┘└────────┘
/// ```
Max(u16),
/// Applies a length constraint to the element
///
/// The element size is set to the specified amount.
///
/// # Examples
///
/// `[Length(20), Fixed(20)]`
///
/// ```plain
/// ┌────────────────────────────┐┌──────────────────┐
/// │ 30 px ││ 20 px │
/// └────────────────────────────┘└──────────────────┘
/// ```
///
/// `[Length(20), Length(20)]`
///
/// ```plain
/// ┌──────────────────┐┌────────────────────────────┐
/// │ 20 px ││ 30 px │
/// └──────────────────┘└────────────────────────────┘
/// ```
Length(u16),
/// Applies a percentage of the available space to the element
///
/// Converts the given percentage to a floating-point value and multiplies that with area.
@ -91,51 +184,6 @@ pub enum Constraint {
/// └───────────┘└──────────┘└───────────┘└──────────┘
/// ```
Ratio(u32, u32),
/// Applies a fixed size to the element
///
/// The element size is set to the specified amount.
/// [`Constraint::Fixed`] will take precedence over all other constraints.
///
/// # Examples
///
/// `[Fixed(40), Proportional(1)]`
///
/// ```plain
/// ┌──────────────────────────────────────┐┌────────┐
/// │ 40 px ││ 10 px │
/// └──────────────────────────────────────┘└────────┘
/// ```
///
/// `[Fixed(20), Fixed(20), Proportional(1)]`
///
/// ```plain
/// ┌──────────────────┐┌──────────────────┐┌────────┐
/// │ 20 px ││ 20 px ││ 10 px │
/// └──────────────────┘└──────────────────┘└────────┘
/// ```
Fixed(u16),
/// Applies a length constraint to the element
///
/// The element size is set to the specified amount.
///
/// # Examples
///
/// `[Length(20), Fixed(20)]`
///
/// ```plain
/// ┌────────────────────────────┐┌──────────────────┐
/// │ 30 px ││ 20 px │
/// └────────────────────────────┘└──────────────────┘
/// ```
///
/// `[Length(20), Length(20)]`
///
/// ```plain
/// ┌──────────────────┐┌────────────────────────────┐
/// │ 20 px ││ 30 px │
/// └──────────────────┘└────────────────────────────┘
/// ```
Length(u16),
/// Applies the scaling factor proportional to all other [`Constraint::Proportional`] elements
/// to fill excess space
///
@ -161,50 +209,6 @@ pub enum Constraint {
/// └───────────┘└───────────────────────┘└──────────┘
/// ```
Proportional(u16),
/// Applies a maximum size constraint to the element
///
/// The element size is set to at most the specified amount.
///
/// # Examples
///
/// `[Percentage(100), Min(20)]`
///
/// ```plain
/// ┌────────────────────────────┐┌──────────────────┐
/// │ 30 px ││ 20 px │
/// └────────────────────────────┘└──────────────────┘
/// ```
///
/// `[Percentage(100), Min(10)]`
///
/// ```plain
/// ┌──────────────────────────────────────┐┌────────┐
/// │ 40 px ││ 10 px │
/// └──────────────────────────────────────┘└────────┘
/// ```
Max(u16),
/// Applies a minimum size constraint to the element
///
/// The element size is set to at least the specified amount.
///
/// # Examples
///
/// `[Percentage(100), Min(20)]`
///
/// ```plain
/// ┌────────────────────────────┐┌──────────────────┐
/// │ 30 px ││ 20 px │
/// └────────────────────────────┘└──────────────────┘
/// ```
///
/// `[Percentage(100), Min(10)]`
///
/// ```plain
/// ┌──────────────────────────────────────┐┌────────┐
/// │ 40 px ││ 10 px │
/// └──────────────────────────────────────┘└────────┘
/// ```
Min(u16),
}
impl Constraint {

View File

@ -206,7 +206,6 @@ pub enum Flex {
/// # Examples
///
/// ```plain
///
/// <------------------------------------80 px------------------------------------->
/// ┌────16 px─────┐ ┌──────20 px───────┐ ┌──────20 px───────┐
/// │Percentage(20)│ │ Length(20) │ │ Fixed(20) │

File diff suppressed because it is too large Load Diff

View File

@ -399,28 +399,26 @@ fn widgets_table_columns_widths_can_use_mixed_constraints() {
]),
);
// This test is unstable and should not be in the test suite
//
// // columns of large size (>100% total) hide the last column
// test_case(
// &[
// Constraint::Percentage(60),
// Constraint::Length(10),
// Constraint::Proportional(60),
// ],
// Buffer::with_lines(vec![
// "┌────────────────────────────┐",
// "│Head1 Head2 │",
// "│ │",
// "│Row11 Row12 │",
// "│Row21 Row22 │",
// "│Row31 Row32 │",
// "│Row41 Row42 │",
// "│ │",
// "│ │",
// "└────────────────────────────┘",
// ]),
// );
// columns of large size (>100% total) hide the last column
test_case(
&[
Constraint::Percentage(60),
Constraint::Length(10),
Constraint::Percentage(60),
],
Buffer::with_lines(vec![
"┌────────────────────────────┐",
"│Head1 Head2 │",
"│ │",
"│Row11 Row12 │",
"│Row21 Row22 │",
"│Row31 Row32 │",
"│Row41 Row42 │",
"│ │",
"│ │",
"└────────────────────────────┘",
]),
);
}
#[test]