State, borrowing widgets, etc #164
Replies: 2 comments 7 replies
-
My personal opinion is that state sharing and borrow-to-render share a similar root causes, and are inter-related. I've spent way too much time thinking about the model of this library, and I really like the concept, but there's some warts, and I don't think they are in the obvious places (borrowing, accessors). So I'll start with an explanation of the core concept that's deeper than the (pretty scant) documentation: Widgets are just a fancy way to pass arguments to a function. The name "Widget" and a long tradition of Object Oriented UI libraries sort of embeds the notion of widget == object into everyone's heads. This makes a lot of sense in retained mode GUIs. ratatui was designed as an immediate mode GUI though, everything is drawn on each pass. For that functions make a lot of sense - just pass in the data and parameters and get the ui is rendered. This core idea is captured in the docs too:
Between that and the consuming render and the let paragraph = Paragraph::new(text.clone())
.style(Style::default().fg(Color::Gray))
.block(create_block("Right alignment, with wrap"))
.alignment(Alignment::Right)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, chunks[2]); Is just a way to write this function call, but with a bunch of benefits: /// signature
impl Frame {
fn render_paragraph<'a>(&mut self, area: Rect, text: Text<'a>, block: Option<Block>, style: Option<Style>, wrap: Option<Wrap>, scroll: Option<(u16, u16)>, alignment: Option<Alignment>) {...}
}
/// call
f.render_paragraph(chunks[2], text.clone(), Some(create_block(...)), Style::default().fg(...), Wrap {trim: true}, None, None); That's a gross function signature. In python I'd do something like: def render_paragraph(self, rect, text, **kw):
buffer = self.get_buffer()
style = kw.get("style", Style())
... To allow for only setting non-default options. Rust doesn't have variadic function arguments though, every function must be called with all arguments. Having a builder struct for the arguments instead, reduces the visual clutter of long argument lists, and makes it easier to determine which parameters are set (since rust also doesn't have It's a neat pattern for simplifying complex function calls and allowing changes without a lot of hassle downstream. **What does this have to do with borrowing and state accessors? ** Well, a lot actually. First I want to acknowledge that borrowing from a parameter struct in Rather, I think widget reuse doesn't make a lot of sense conceptually in terms of the "widgets are fancy function calls". You set your arguments up every time you call a function, and keeping that notion in the "fancy" approach keeps the pattern familiar. There's an efficiency argument that I've seen made for the borrowed type, but in reality it's really not much different to do: let a = 2;
for b in 1..5 {
do_somthing(a, b);
} Than it is to do: for b in 1..5 {
let args = ArgBuilder::new().a(2).b(b);
do_something(args);
// or do_somthing(&args)
} They will compile and optimize similarly. This is non-intuitive to me, it "feels" more expensive somehow, but play around with the idea in the playground and check out the assembly - they end up very similar, particularly after optimization ( Almost all of the parameters (Style, Wrap, Color, etc) are very small copy types like usize, so for them, the above is quite true, and the widgets themselves aren't very big from them as a result. Where the above falls apart is: the buffer and the drawn data (the vec or the Text in pargarph for example) is not a necessarily a small copy type. For the Buffer, it's passed to render as This is strike 1 against owned "dawn data" types. I think it would make a lot more sense to allow reference types to the drawn data. Lets revisit the function call, but with a slightly simplified signature:
Technically it's But why require &Text or &[ListItem] in the first place? I don't know that ListItem or Text need to even be types. It dictates a lot about the rest of the program structure in uncomfortable ways. I want to represent my data in ways that fit the program not the libraries. So to work around it I have to either create a This is strike 2 against the drawn data types. I think a better solution would be to implement the passed types as traits... Aside: A similar rationale can be applied to ListState (et al), they can be traits that provide the relevant info via a function call or two) This seems orthoganal to borrowed render It is a bit askew anway. I don't think the above stuff directly contradicts having a borrowed render. I do think it strongly influenced the desire though. The owned "drawn data" types and strict type requirement both contribute to making the widget feel like some sort of object rather than a fancy function call - giving "large" data to a struct usually signals "this struct should live for a while", rather than "this is function args", I think removing that ownership will do a lot for that intuition though. I applied this idea for the Calendar widget (DateStyler is a trait, and the reference implementation impls the trait on reference types as well as owned), and in a personal project, DateStyler is implemented on a data type that has a bunch of data (incl a date) allowing me to just pass my data to a Calendar without much hassle. This is strike 3 - owned and dictated "drawn data" types aren't necessary and they suggest use of widgets to store state and be long lived rather than as fancy functions. ** Back to the composite widgets ** Composite widgets (widgets made of widgets) are currently pretty hard to do unless the composition is all one widget type. Heterogeneous collections are kind of a pain in rust no matter how you slice it. The most straight-forward way is
Ok so I just spent a lot of words convincing myself that maybe there's a good reason to do borrowed renders after all. I'm certainly not going to delete it - I also got to lay out my traits idea nicely, and is probably a good thing to point to in the future for various feature requests (should we continue down the immediate mode path) because I really still don't like the idea of long-lived widgets even if borrowing for render is good idea. The trait path solves for that.
I almost forgot to tie that in - accessor functions are mostly useful for getting at owned data. If we go down the trait path, and let people define data how they see fit, they're less likely to need to access that data via the widget, and futher encourage keeping state and widgets separate. Some other random thoughts on how the using traits for drawn data could be useful:
Anyway... wall of text over. Thanks for reading my treatise on how I changed my mind on borrowed render, and also I'm curious on what folks think about the rest of the stuff I presented. Also, there's probably a lot of other takes out there, so I'm interested in how you all see this stuff too 😄 |
Beta Was this translation helpful? Give feedback.
-
#833 is an approach that addresses much of this problem by changing our widgets to implement Most of the widgets only needed to mutate their internal state because they hold an The upshot of that PR is that you can now (for the implemented widgets):
There's still a couple of widgets still to do for this, but I think that solves most of this issue. |
Beta Was this translation helpful? Give feedback.
-
There's a lot of discussion in tickets about changing the
render
functions to borrow a widget (e.g. #122, #132, #16 ). There's also a bit of discussion about giving more access to internal widget state, and reusing widget structs. I think it makes sense to gather those discussions into one place - if nothing else, they add up to quite a bit of architectural change and a consolidated discussion might help a lot.Beta Was this translation helpful? Give feedback.
All reactions