Flex Layout - Draft / PoC / RFC
Description
When I was using the Layout system from tui-rs, I was a bit frustrated with some aspects of it. In my case I wanted to center stuff or have a gutter between columns which were both not really nice to use with the Layout as of now. I also would have loved to add more complex constraints.
I know that @fdehau was trying to solve some or all of these issues in #519, but I felt like trying out a different approach by stealing from what I know well: The CSS flex layouting.
This PR is a (not polished, but working) implementation of a rather simplified self-written flex layouting algorithm. For now it copies most of the structure Layout already has (like the split function mechanics), while adding a little bit of additional utility, like try_split. I'll explain it further below.
I don't know how well this performs against the existing cassowary constraints, but I do think that the flex layouting is pretty simple and easy to calculate. This could get tested more formally of course.
This could potentially also be rewritten to replace the existing Layout while trying to keep most of it's features, but I liked the idea of starting fresh - maybe even have it as an alternative alongside the other Layout and see which one gets better feedback from library consumers.
The Flex Algorithm
Like with the existing Layout you can construct a FlexLayout by setting a direction and defining a collection of Constraints (for now called "Flex Spaces").
Each Flex Space has
- a base size (like
flex-basisin CSS) - optionally the possibility to grow (weighted, like
flex-growin CSS) - optionally the possibility to shrink (weighted, like
flex-shrinkin CSS)
You can additionally set a maximum/minimum size to grow/shrink to, that would be similar to max-width/min-width etc. in CSS.
The layouting algorithm called during split() then has three possible scenarios:
- All the base sizes add up to the exact amount of space we have
- Then each space gets exactly its base_size.
- There is extra space after adding up the base sizes
- Then all "growable" items should get some portion of that extra space, with respect for their weighting (
FlexGrow::flex_share)
- Then all "growable" items should get some portion of that extra space, with respect for their weighting (
- There is not enough space to fit all the base sizes
- Then all "shrinkable" items should shrink enough to fit into the space, with respect for their weighting (
FlexShrink::flex_share)
- Then all "shrinkable" items should shrink enough to fit into the space, with respect for their weighting (
There is a more detailed description of the programmatic implementation of this in the code
I personally really like these rules, because they are what I already know from the web and their constraints give me a productive framework to think in (layout has a base size and is either shrinking or growing) while being really powerful.
It's less powerful than more specialised cassowary constraints but also much simpler to reason about.
Usage examples
A three-column layout with the columns taking up 50 / 25 / 25 % of the space - infinitely shrinkable
let chunks = FlexLayout::new(Direction::Horizontal)
.flex_spaces([
FlexSpace::new(0).growth(2),
FlexSpace::new(0).growth(1),
FlexSpace::new(0).growth(1),
])
.split(area);
But to see some more complicated stuff, let's say
- The three columns should take (50%, 25%, 25%) of their space when they have their ideal size of (20, 10, 10)
- When there is space, the three columns should grow at an equal rate (This would create a non-constant but more natural feeling ratio when dealing with text)
- The three columns can shrink to a minimum of (5, 5, 5) when there is not enough space
- The very first column shrinks twice as fast as the others.
- Sometimes, there is an additional fourth column at the end with a static size of 4
- There should be a margin of 1 space (only applied in layout direction, see note later on)
- There should be a gap of 3 spaces between each column.
- The gap is the first to shrink to 1 space when we're out of space
// numbers in comments relate to which part from the list above is configured at that point
let mut layout_columns = vec![
// [1.] [2.] [4.] [3.]
FlexSpace::new(20).growth(1).shrinkage(FlexShrink::new(2).min_size(5)),
FlexSpace::new(10).growth(1).shrinkage(FlexShrink::new(1).min_size(5)),
FlexSpace::new(10).growth(1).shrinkage(FlexShrink::new(1).min_size(5)),
]
if has_fourth_column {
// [5.]
layout_columns.push(FlexSpace::new(4));
}
let chunks = FlexLayout::new(Direction::Horizontal)
// [6.]
.margins(1)
// [7.] [8.]
.gap(FlexSpace::new(3).shrinkage(FlexShrink::new(1000).min_size(1)))
.flex_spaces(layout_columns);
.split(area);
There are some more examples on the simple side in the PR changes because I replaced the usage of Layout with FlexLayout in the demo example. (The example uses the new try_split method, but could just as well use split on the flex layout to behave the same as before)
Bonus features (some of which could be applied to Layout as well)
- I added a
try_splitmethod which will return Result that potentially contains aLayoutOverflowError, when the elements don't fit into the space that was provided, i.e. the added up minimum sizes are smaller than the area. - One can specify a
gapon the layout which is inserted between each passed space- The gap may be fix, but can also be a Flex Space and receive growing/shrinking behaviour
- One can specify a
margin_startandmargin_end- This is somewhat different to the existing
Layoutmargins, as it is only possible to apply them in the layout direction. - This was necessary because they are also allowed to be Flex Spaces. In theory we could run the flex algorithm again to calculate margins in the across direction, but I wasn't sure yet what would be best.
- This is somewhat different to the existing
The gap and margin are just some syntax sugar to add these flex spaces to the layout, but remove them from the output, as their areas are usually not relevant.
Testing guidelines
You can try replacing some Layouts with FlexLayouts and see how it feels and make sure that nothing breaks. Especially interesting would be trying to implement layouts that didn't seem feasible or took a lot of extra calculations to make work before.
Of course nothing should crash or behave in an unexpected way.
In the PR I changed the demo example to make use of FlexLayout, and added a little feature that it tells the user to make their terminal bigger if the layout doesn't fit into its area.
Checklist
- [x] I have read the contributing guidelines.
- [ ] I have added relevant tests. (I added one test but there should definitely be more)
- [ ] I have documented all new additions. (I added a bit of documentation, but there should probably be more)
Feedback:
Maybe that's obvious, but I would love to get some feedback on:
- The idea - What do you think of having a layouting system based on flex instead of the existing cassowary constraints? If you like the Flex Layout, would you say it should exist alongside the other Layout, or replace it?
- Code style - I'm rather new to Rust and don't know if I'm doing some big anti-patterns, or if something is usually written differently (Though at least clippy didn't complain). Any nitpick is appreciated.
- The APIs
- The implementation
Sorry, this was supposed to be a draft pull request, but I forgot to change it before clicking the button. I can't figure out how / if I can change it to a draft now.
I'd also be happy with moving the discussion and feedback on the idea to an issue for now, and just linking there to my branch or a Draft PR.
This looks amazing, feeling the exact same things you described! Would LOVE to see this merged
Hey @remmycat 👋 First of all, sorry for the super late answer. This looks indeed amazing 🤩. I'll need a bit of time to review it (others are obviously welcome to do so as well).
To answer your initial set of questions:
-
The idea: I like it a lot. Flex layout has always been a goal but I never found the time to study it and write an implementation that would fit our usage in
tui, hence my failed attempt with #519 on top ofcassowary. -
Code style: This is a very solid contribution, I didn't notice anything obvious from a quick look.
-
The API: the API looks already pretty clean. Given that your solution seems to be a superset of the existing capabilities, I would be in favor of just replacing
Layoutwith it 😄. This could maybe allow us to maybe drop a fewflexin the struct and function names and simply mention in the doc that the layout algorithm works like flex. -
The implementation: I need to give it a proper review before being able to say anything.
Hi @fdehau and @remmycat - I came across this PR after looking into writing something similar myself, and I'm curious if I can help at all with getting it "production ready". I'd certainly like to use it in my own applications. If having someone else test it, and perhaps review code (to the best of my ability, anyways), let me know. Thank you!