This is an analysis of the architecture of the Druid framework, and how I think it should evolve in the near future.
Druid is an experimental GUI framework in the Rust programming language; it's somewhere between declarative and object-oriented, and implements a high-level abstraction for platform-specific architectures. It's also the base for crochet, an immediate mode GUI library, and panoramix, a reactive GUI library; both of them extremely experimental.
Recently, Druid's author Raph Levien started talking about the idea of refactoring druid into multiple layers (a simple "backend layer", and one or more frontends like crochet and panoramix). We've discussed possibilities, and Raph has described some (still hypothetical) design decisions that I want to challenge.
Now, separating a framework into tight layers isn't fundamentally a good or bad idea. Sometimes it helps isolate orthogonal logical concepts; sometimes it's over-engineering that makes using the framework more complicated. But in Druid's case, I think the idea has merit.
Druid is a library that manages both the internal state of a widget graph (with all the complexity and platform-dependent implementation details that entails) and a declarative abstraction for end users to produce that widget graph.
Speaking from my experience building Panoramix on top of Druid, it often felt like the user-facing abstractions "got in the way". A dumber version of Druid, serving as a kind of "intermediate representation" between declarative frameworks and graphics primitives would definitely fill a useful niche.
This article goes over various GUI concepts and how Druid relates to them; it mentions Raph's proposed changes, and my own counter-propositions, hopefully in an unbiased way that covers each argument fairly.
- Note: This article will often employ the word "user". When it does, it always means "the developer using the library to build a GUI application", not "the person using the finished app".
At the conceptual level, any GUI framework is built around a tree of widgets communicating with application logic. The framework emits events, and receives mutations it must apply to the tree.
Where frameworks differ is in how they represent these states and interactions in their type system.
Widgets are pieces of data and behavior representing a distinct element of the GUI. Widgets can be leaves (button, paragraph of text, etc), or containers for other widgets (scrollable area, window, etc).
Widgets are traditionally represented using object-oriented programming: there is a general Widget class with general-purpose methods (get_size(), paint(), get_children(), get_parent()), and a bunch of subclasses for specific widget types (eg CheckboxWidget) with specific methods (eg toggle()).
A custom Widget in a traditional C++ framework will usually look like:
class MyWidget : public Widget {
public:
MyWidget(Widget* parent, SomeData foobar);
void paint(PaintManager*) const override;
Size getSize() const override;
// other methods
}The Widget tree is an area where Rust frameworks are by necessity different than most other GUI frameworks: in traditional frameworks, widgets will often store a pointer to their parent in the tree, or even their siblings, which is difficult in Rust due to lifetime rules.
One noteworthy implementation of the Widget tree is the DOM, the abstraction representing the content of a web page in all browsers. While it has similarities with an OOP implementation (for instance, HTMLDivElement inherits from Element), the developer is expected to use dynamically-typed methods like createElement(), appendChild(), addEventListener() and setAttribute() to mutate the tree.
Druid currently has the Widget trait and the WidgetPod generic struct, which together form something similar to the OOP approach.
When implementing a custom widget, you usually create a struct storing the internal data of that widget, and implementing the Widget trait:
struct MyWidget {
foo: Foo,
bar: Bar,
}
impl Widget<MyData> for MyWidget {
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut MyData, env: &Env) {
// ...
}
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &MyData, env: &Env) {
// ...
}
fn update(&mut self, ctx: &mut UpdateCtx, old_data: &MyData, data: &MyData, env: &Env) {
// ...
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &MyData, env: &Env) -> Size {
// ...
}
fn paint(&mut self, ctx: &mut PaintCtx, data: &MyData, env: &Env) {
// ...
}
}The generic parameter to the Widget trait (MyData in our example) is the Data parameter. The concept is similar to React props and Vue.js v-models. It represents data that must be provided by the parent widget, and can be mutated by our widget's events. My understanding is that leaf widgets should implement the Widget trait with a specific data type (eg a checkbox implements Widget<bool>) whereas container widgets should be generic over data types.
An interesting property of Druid is that child widgets are stored directly in the widget's Self, using the WidgetPod type. The framework itself doesn't have a generic Widget::add_child() method. Rather, custom widgets are supposed to manage their children themselves, and to notify the framework when a child is added or removed. A custom widget with children might look like:
struct MyContainerWidget {
foo: Foo,
bar: Bar,
first_child: WidgetPod<MyData, MyWidget>,
second_child: WidgetPod<u16, OtherWidget<u16>>,
more_children: Vec<WidgetPod<bool, Checkbox>>,
}(one downside to this approach as implemented is that, if you're improperly implementing a custom container widget and you forget to notify the framework properly, it's not immediately clear what you did wrong because the problem is only detected in subsequent passes, when you've already left the callstack where the omission happened)
(like, seriously, almost every single error I've gotten from druid have been Widget received an event without having been laid out and event method called before receiving WidgetAdded)
Raph proposes a major change to this API: getting rid of the Data parameter, and representing changes to the widget tree as a dynamically-typed tree of mutations. Each mutation would be something like:
// To be clear, this is my pseudocode, not something Raph wrote
enum Mutation {
Insert(Payload),
Delete,
Update(Payload),
UpdateChildren(Vec<Mutation>),
LeaveUnchanged,
SwapWithSibling(u32),
}where Payload would be a dynamically-typed widget (either Any or a huge enum); this implies that the only way to edit a widget would be to replace it with a freshly constructed widget (unless payload types also include specific mutations, eg SetText(String), Resize(Size), ChangeStyle(CssInfo), etc).
This is the architecture Crochet currently uses as a middle-end: after the new GUI tree is generated, Crochet generates a mutation tree; the mutation tree is iterated on to modify a quasi-dynamically-typed AnyWidget (though Crochet does this is an awkward way, due to being an immediate-mode GUI, where the same data represents both the new widget tree and the mutation tree).
Applied to Druid, this architecture might look like:
// Again, pseudo-code
trait Widget {
fn apply_mutations(&mut self, ctx: &mut UpdateCtx, env: &Env, mutations: &Vec<Mutation>);
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, env: &Env) -> Size;
fn paint(&mut self, ctx: &mut PaintCtx, env: &Env);
}(though in Raph's plan the least-common-denominator type wouldn't be the widget, but a more general node type that would also cover stuff like windows and menus; I would add vector graphics primitives to the list)
The main advantage of this architecture is that it makes mutation batching (eg change 20 labels at once) easier to reason about. The main downside is that it makes interfaces harder to document.
The core of event handling is giving the user a way to express, "When X happens in my app, react by doing Y". For instance, "When this button is pressed, add an item to this list".
For any given action, GUI frameworks need a way for users to specify two parameters:
- Which event is being bound. Either a platform event (eg "keyboard key pressed") or a GUI event (eg "button clicked").
- What change does the event trigger (eg "add item to list").
For most GUI frameworks, event handling is almost always a variant of:
my_gui_element.on_mouse_click((event) => {
list.add_item(foobar);
});- The event is expressed by the method (eg
on_mouse_click) - The change is expressed by the user-provided callback (eg
(event) => list.add_item(foobar)).
A more recent alternative is the Elm architecture, where event handling is more declarative. The general concept looks like this pseudocode:
MyApp:
data:
list_data
render:
Button(Click => AddItem)
SomeList(list_data)
handle_event (event):
switch (event.type):
AddItem => list_data.push(foobar)
That is, every widget declares events it can emit. A custom widget declares how events emitted by its children map to its own events. The custom widget also has an event handling method that takes local events and modifies local data from them.
(additionally, Elm itself uses immutable data structures, so instead of list_data.push(foobar), you'd have something like return concat(list_data, foobar))
In this architecture:
- The event is expressed by the dynamic type / enum variant (eg
Click,AddItem) - The change is expressed by the event-to-event mapping (eg
Click => AddItem) and the respective switch branch (egAddItem => list_data.push(foobar)).
The Elm architecture is a good fit for functional programming languages, because it requires little to no mutating of data. Events are propagated by being returned, not by chaining callbacks; which means the calling scope has a greater degree of control over what data can be mutated.
Iced is the most popular Rust GUI framework using the Elm architecture.
Druid's event handling story is somewhat half-baked.
Architecturally, it mostly looks like the Elm model. Widgets have an event() method that takes an Event, and a mutable reference to the Widget's data.
(Remember, the Widget trait has a Data generic parameter, which represent a state that is stored alongside the widget; event() is the only method that can mutate that state)
Most variants of druid::Event represent platform events, except for Command (a message from an arbitrary origin in the app) and Notification (a message from a child widget).
Both Command and Notification feel a bit tacked-on. They're stringly-typed, most widgets don't emit them, and there is no straightforward syntax to map the event of a child widget to a local event; in other words, there's no equivalent to the Click => AddItem syntax of Elm-like architectures.
While I've mentioned two user-facing event systems, the event system Raph is planning isn't user-facing.
The idea is that, if we're planning to refactor Druid into an "intermediate representation" layer, then the event system of that layer should be convenient for frontend writers, not for human users.
The system Raph proposes would separate platform events (MouseMove, KeyPressed, etc) and actions (ButtonClicked, TextBoxEdited, etc). Platform events would still be handled locally in an event() method. Actions would be emitted to a global queue.
The queue might look like:
struct DruidActionQueue {
VecDeque<(WidgetId, Action)>
}(this is similar to the event queue Crochet and Panoramix currently use)
Frontends would have to implement their desired abstraction (event callbacks, Elm architecture or something else) on top of that queue, with a polling model. Down the line, Druid would probably need to give frontends more detailed information, to help them poll only the data they strictly need.
This would be a massive simplification over Druid's current system. Currently, frontends need to implement their own event queue over Druid's data architecture; this gets harder in the case of widgets like TextBox and Checkbox that don't emit any events when their content changes.
Performance, according to Raph, one of the major reasons driving a potential refactor of druid.
I'm not a browser developer, and I'm not super familiar with the low-level internals of existing frameworks, so this section is going to be a little fuzzy.
That said, performance is a critical part of GUI frameworks:
- GUI apps should be reliable (no missed frames, avoid freezing the UI for seconds, etc).
- While 90% of users don't notice small performance differences, some users are really attentive to them and will eg choose their text editor based on 5ms differences in reactivity.
- GUIs should scale well; sooner or later, any framework will be used to display a form with hundreds of fields, a list with thousands of item, etc. Text editors need to handle files of arbitrary size.
- As a corollary, developing a GUI in a way that scales well should be the path of least resistance (the "pit of success" design principle).
- Mobile apps are resource-constrained and need GUIs to be reactive.
- While video game developers usually tolerate resource-hungry GUIs, any CPU time sunk into painting GUI is still CPU time that isn't used for game-related computations.
Also, Raph pointed out at some point that it's important that performance is easy to reason about; tricks that improve performance 90% of the time but make it awful 10% should be avoided; if there's a performance bottleneck, the user shouldn't have a hard time finding it.
Generally speaking, the driving loop of a GUI framework is:
- You give the framework a widget tree.
- The framework computes the size and position of every widget, according to complex, arcane, sometimes contradictory rules.
- At a later point you mutate the widget tree.
- The framework must now recompute the size and position of every widget accordingly. Ideally it only recomputes the data it strictly needs to, and reuses the data from previous computations.
(if your first thought is "that sounds like a compiler", you're not the first to make the connection; I'm pretty sure at least one salsa dev has made it, though I don't remember where)
The update algorithm is non-trivial. For aesthetic reasons, we often want the size of a GUI element to depend on the size of other elements: the height of a paragraph depends on the available width, which might depend on the widest element in the group, etc. So it's not as simple as "if the widget's content is unchanged, then its layout is the same".
The framework does its best to implement simplified versions of these rules to improve performance when it can, and falls back to more expensive general rules otherwise. Much like a compiler, the framework will sometimes make a wrong assumption and cache something that should be updated or use a simplified rule when the general one should be used, and a graphical bug will occur.
The problem can be compounded when application logic performs multiple changes in a row. For instance, if 10 items are inserted at the beginning of a list of 100 items, and each insertion causes the entire list to be laid out again (because we need to be able to access the layout at any time), then we end up running more than 900 superfluous layout recomputations.
I think there are two common high-level approaches to performance and incrementality:
- Batching changes
- Dirty flags
Batch changes are self-explanatory: first you compute a big list of widget tree mutations, then you perform all the mutations at once, and then you compute the new layout, a single time. Ideally, you want to apply a single batch per frame, with all the mutations since the last frame.
Dirty flags are a little more granular: every time you apply a widget mutation, the widget implicitly sets a "dirty" flag to mark itself as modified. Before querying a widget's layout, the framework must recompute it if the widget is marked as dirty. Dirtying a widget usually means dirtying its parents as well.
(implementations vary in what happens if the framework queries a widget's dirty layout before recomputing it; it can be garbage data, like the previous value or a default value or even uninitialized memory; it can be a runtime exception; or the widget can just recompute the layout on the spot)
Note that, in the case where querying a widget's layout is forbidden during the entire pass where layout are dirtied and recomputed, the dirtying strategy becomes effectively equivalent to the batching strategy.
Druid so far mostly uses dirty flags to recompute layout as often as needed.
The implementation is fairly pessimistic, and errs to the side of assuming that layouts should be recomputed when any parameter changes. Some examples of dirty flags are TextLayout::needs_rebuild() and WidgetState.needs_layout.
Using a widget that hasn't been properly laid out usually results in a runtime warning (in practice, a lot of them, since they repeat every MouseMove event), and uses either previous or default values.
To my knowledge, Druid doesn't implement batch mutation anywhere.
Raph intends to leverage the mutation API described above to perform batch mutations.
The idea is that the new Widget trait would have a method for taking mutations:
fn apply_mutations(&mut self, ctx: &mut UpdateCtx, env: &Env, mutations: &Vec<Mutation>);This is easy to reason about: every frame, the application code receives a queue of events from the widget tree, and sends a tree of mutations. Mutations are already batched, and can be applied in a single pass.
Also, this makes it easier to reason about performance: the framework can simply log the mutation tree, and the user can check if lag spikes correlate with large mutation trees.
Widget composition is about letting the user combine widgets they wrote with pre-defined widgets.
If the user writes MyContainerWidget and MySpecialButton, they'll want an easy way to express a widget tree that look like:
MyContainerWidget
Label
Label
GroupBox
MySpecialButton
TextBox
There are a lot of GUI composition strategies out there.
To stay concise, I'll only mention three:
-
Imperative: You can add a child to a Widget by calling an
add_childmethod.let container_widget = ContainerWidget::new(foobar); let my_widget = MyWidget::new(foo); let my_widget_2 = MyWidget::new(bar); container_widget.add_child(my_widget); container_widget.add_child(my_widget_2);
-
Reactive: Widgets are functions (components) that return trees of other widgets.
return ContainerWidget::new(foobar, [ MyWidget::new(foo), MyWidget::new(bar), ])
-
Immediate-mode: Adding a widget is a function call. Adding a container widget creates a span inside of which any widget created is a child widget.
// ctx is a context parameter ContainerWidget(ctx, foobar).with_children(|ctx| { MyWidget(ctx, foo); MyWidget(ctx, bar); });
Crochet is immediate-mode. Panoramix is reactive.
Druid is kind of awful at composition.
It has a reactive composition syntax that works if the dataflow can be expressed with static types, and... that's it.
If your dataflow is a little more complicated, your only choice is to get your hands dirty and start building writing widgets.
The mutation API is an imperative architecture at its core.
Normally, this would be frowned upon: imperative GUIs aren't very user-friendly, and most big-names imperative frameworks are from the 2000s or earlier.
But we're trying to make a "backend" layer; we don't need it to be user-friendly, and imperative backends are easy to plug into. Crochet and Panoramix already provided user-oriented abstractions.
Finally, Raph mentioned that a goal of Druid is to integrate Python scripts.
The idea would be that, for an app like Linebender, you could plug custom scripts into the main app, that would define their own widgets and app logic.
The deeper idea is that, if we compare Druid to a compiler IR of sorts, then we could "link" the output of multiple frontends, like Crochet, Panoramix and that hypothetical Python port.
The mutation API being dynamically typed might be an advantage here, when porting to Python semantics.
So far I've tried to make a factual survey of existing GUI concepts, and of Raph's proposal as he explained it.
The following is completely subjective, and describes how I think Druid's API should evolve.
The central idea of Raph's proposed refactoring is a dynamically-typed node tree, where nodes can be widgets or other UI elements; and all nodes share a mutate() method that takes a dynamically-typed mutation type.
I don't think this is a good idea.
Now, Raph is a veteran UI developer with years of experience in Android's text stack, whereas I'm a junior developer fresh out of college, so it should be obvious that I'm right and he's wrong. But just in case you need more persuasion, I'll make my case.
First off, I don't think dynamic typing and static typing is that different in terms of code produced.
I expect that most of the widget internals you'd end up writing would be the same code as the static version, only with more indirection:
// Static-types version
fn insert_child(new_child: Payload, ...) {
do_internal_stuff_to_add_child(new_child);
}
// Dynamic-types version
fn mutate(...) {
// ...
if let Mutation::Insert(new_child) = mutation {
do_internal_stuff_to_add_child(new_child);
}
}If the API includes granular mutation types, these granular mutations also need to be implemented:
// Static-types version
fn set_text(new_text: Text, ...) {
modify_internals_to_set_text(new_text);
}
// Dynamic-types version
fn mutate(...) {
// ...
if let Mutation::SetText(new_text) = mutation {
modify_internals_to_set_text(new_text);
}
}Widget types have some common features that can be pulled in a Widget trait; stuff like needing to be laid out, needing to be painted, catching platform events, etc; things that the existing Widget trait already abstracts over.
But different widget types have very little in common when it comes to what state they hold and how they can be mutated. Applying a SetText mutation to a flex container or a AddChildren mutation to a button has no reasonable interpretation (and contorting into giving it one, like making it possible for a button to have children, is a recipe for error).
There's also the documentation problem. In the above example, if set_text() is a method for a Checkbox widget, then the Checkbox documentation will automatically include the method. IDE users will be able to write checkbox.set and have their IDE complete to checkbox.set_text.
On the other hand, with the mutate() API, there's no trivial way to know that Checkbox accepts SetText mutations. The Checkbox documentation can mention it, but this requires active maintenance, and it's a lot less discoverable.
We can skip some of the problems if the only mutation allowed for a leaf widget is a coarse Update that takes an entire new widget and replace the previous one atomically, but:
- It still adds some non-trivial corner cases (eg what to do if a widget is replaced with a widget of a different type).
- Some widgets have persistent semantics and shouldn't be recreated over and over (eg a widget storing a handle to a network call).
- It means container widgets are required to accept children of any possible widget type, whereas we might want to build container widgets that only accept certain chidren types.
And fundamentally... well, having a strong typing system is one of Rust's selling points. Having an architectural core that replaces it with dynamic typing feels like a strategic error.
The main advantage of the mutation tree approach is that it lets us batch changes implicitly.
I think a question we should ask is "Which use cases would be more performant with batching?" and "Can we express these use cases with similar performance with an imperative syntax?"
For instance, a use case where batching is beneficial is the insertion of lots of elements.
Instead of doing this:
widget.insert(NewElement());
widget.insert(NewElement());
widget.insert(NewElement());
widget.insert(NewElement());It's more efficient to do this:
widget.apply_mutations([
Insert(NewElement()),
Insert(NewElement()),
Insert(NewElement()),
Insert(NewElement())
])But we could also have a special insert_batch() method:
widget.insert_batch([
NewElement(),
NewElement(),
NewElement(),
NewElement(),
]);Now, there's an argument to be made that the apply_mutations version is easier to optimize. The user doesn't even need to know about batching. The backend can just receive a bunch of mutations and batch them by itself. If Druid comes up with new batching optimizations, it can implement them transparently without the user having to be made aware of them.
Counterpoints:
- The user isn't meant to use these methods directly, and frontend authors will likely be aware of the optimized versions. For instance, if we're using Crochet + Druid, and Crochet produces a list of insertions, it should be able to transform them into a batch insertion on its own.
- I'm skeptical that there are that many batching optimizations that can be implemented transparently. I'd expect most new optimizations to require some buy-in from the frontend.
Overall, my main point is that the Druid API should have "machine sympathy". If Druid benefits from batch insertions, then containers should have an insert_batch() method; if Druid wants to use rotation to reorder children, then containers should have a rotate_children(k, n) method; and so on.
In general, I feel the "dynamic mutation tree" approach is too clever for a back-end layer. I think Druid should be dumb enough to make it trivial to guess what any part of the API does, whereas the mutation tree architecture requires a bunch of non-trivial choices.
That said, I think Druid could still implement a stub of a mutation tree API, that frontends like Crochet could expand upon.
To illustrate, say we have a Column widget with these methods:
impl Column {
pub fn insert(&mut self, child: impl Widget<T>, i: usize);
pub fn delete(&mut self, i: usize);
pub fn get(&self, i: usize) -> &dyn Widget<T>;
pub fn get_mut(&mut self, i: usize) -> &mut dyn Widget<T>;
pub fn rotate(&mut self, child_idx: usize, new_idx: usize);
}(note that this is already different from the existing Flex, which can only append children, and lacks other CRUD methods)
We could enrich this declaration with attributes:
#[derive(Mutation(Payload="Box<dyn Widget>"))]
impl Column {
#[Mutation::Insert]
pub fn insert(&mut self, child: impl Widget, i: usize);
#[Mutation::Delete]
pub fn delete(&mut self, i: usize);
pub fn get(&self, i: usize) -> &Box<dyn Widget>;
#[Mutation::Update]
pub fn get_mut(&mut self, i: usize) -> &mut Box<dyn Widget>;
#[Mutation::SwapWithSibling]
pub fn rotate(&mut self, child_idx: usize, new_idx: usize);
}which would generate a static Mutation type and an apply_mutations method (probably behind a conditional feature).
Crochet could then use these static types and methods to build its dynamic widget and mutation types.
Right now Druid rebuilds layout pessimistically.
Say you have a Flex widget, storing a list of 100 Label widgets; and one label is changed. The label's needs_layout field will be set to true; this will be spread to its parent. That means the layout() method of the Flex list will be called, which will recursively call the layout() method of every Label.
This is mitigated by the fact that eg Labels do some internal caching to avoid discarding existing shaping information if the layout hasn't changed, but it's still superfluous.
Caching layout is a hard problem, because there are a lot of subtle changes that can impact how layout is calculated, and keeping a layout in cache when such a change occurs is an easy way to create GUI bugs.
I'm not super familiar with that domain, so I won't go into details on potential solutions.
Going back to the compiler analogy I made earlier, it feels like we could implement a solution similar to salsa / the rust compiler: only use pure functions to compute layout; for every major layout function, store a hash of every input; and skip rebuilding the layout when the inputs are unchanged.
(ideally in a way that doesn't add excessive overhead from storing and comparing data)
If we end up using an imperative API (that is, adding a lot of setter methods everywhere), it means we'll add the possibility that the state of the widget tree is modified in ways the framework doesn't control.
Currently, the idiomatic way to update a Widget in Druid is to change the data passed to it. Druid automatically detects that said data is changed, and propagates updates accordingly.
In constrast, the Label::set_text method currently has this warning:
If you change this property, at runtime, you must ensure that
updateis called in order to correctly recompute the text. If you are unsure, callrequest_updateexplicitly.
Imperative APIs currently require that users take responsibility for mutating widgets and notifying the framework. This is somewhat error-prone.
Also, debugging is difficult, because if a notification is omitted, the framework won't raise an error until a subsequent pass, or not at all, which means tracking down where errors come from is difficult.
I think we should design an imperative API with the following constraints:
- All imperative methods should be called in a single pass, to make them easier to reason about, and make it easier to batch changes. That is, there should be a
mutatepass separate fromevent/lifecycle/layout/etc; calling.lifecycle()or.layout()during the mutate pass would be forbidden. - All imperative methods should be logged/traced.
- All widgets should do their own housekeeping when an imperative method is called, including notifying the framework that they've been updated, etc.
- When writing a custom widget and implementing the
Widgettrait, the framework should help the custom widget do its housekeeping by eargerly detecting any missed notification or other problem.
To expand on that last point, take the example of layout(): if widget forgets to call the layout() function of a child, the framework won't raise an error until the event() pass is run, at which point Druid logs the Widget received an event without having been laid out error. I believe the framework should instead log a Widget hasn't been laid out error as soon as the layout() method returns.
To make these checks possible, the Widget trait should have an additional method children(), which would return a Vec<&dyn Widget>, or something similar. The method would be used only in debug mode; the idea being that WidgetPod would, after each call to a Widget method, check that the method properly updated each child and the widget is in a valid state.
I think Druid is approaching the level of maturity where we want to seriously think about performance.
While the common wisdom is that developers should always measure performance before worrying about optimization, GUI developers rarely apply that principle, because measuring performance for a GUI app is often inconvenient.
Druid should implement a suite of benchmarks, taking inspiration from something like the JS framework benchmark. It should test both the latency of small changes, and very large changes, with a variety of operations, and standardized measures. The benchmark should be expanded any time a new optimization is added, to measure the use-cases where this optimization is useful.
One possible benefit of making a pluggable Druid backend would be to pool work with existing Rust GUI frameworks.
Judging from the recent state of Rust GUI article, there are three frameworks that might use Druid as a backend Conrod, Relm and Iced; I would add the Bevy game engine to the list. Other libraries are unmaintained, target specific architectures, or are a straight port of a C++ framework.
It's unclear whether these frameworks would benefit enough from using a common backend to justify the time investment to port to a new API. On the one hand, most of them already have a cross-platform solution; and changing APIs is a serious effort.
On the other hand, most of these libraries don't support as many platforms as Druid does, and are somewhat rudimentary; plugging into Druid would allow then to implicitly benefit from any progress Druid makes in IME support, accessibility, GPU rendering, GPU layout, testability, etc.
I think if we seriously consider pooling effort across the Rust GUI ecosystem, the next step is to have a public discussion, to build a common consensus (even if that consensus is "not worth the effort"). There should be an agreed-on process, like the Rust Working Groups or the Rust OSDev organization.
Having a transparent process is especially important because, initially, communities will always default to keeping their own internally-developed system. Pooling effort means convincing people (including in the Druid community) to lose some control over their work and have to spend effort complying with design choices they didn't make. An open process is necessary (but not sufficient) to avoid hard feelings.
I would suggest starting this process by working on a common project with the Iced community; I think Raph has been talking with the Iced author, and Iced is overall the most developed Rust UI project besides Druid. It also has a backend which is significantly different from Druid's, and also more advanced in some ways.
To summarize the changes I propose:
- Add CRUD methods (
insert/delete/get/rotate) to container widgets. - Add batch-edit methods (
insert_batch) to container widgets. - Use attributes to generate statically-typed Mutation object stubs; generate the dynamically-typed version in a frontend library.
- Implement a query-like syntax; use it to add guaranteed-correct incremental layout.
- Add a
children()method to theWidgettrait. - Document all invariants that widgets have to follow. Eagerly check those invariants in widget mode.
- Write a benchmark to find performance bottlenecks, and test potential optimizations.
- Start a common project with the Iced community, to find common ground and try to figure out what a common backend might look like.
With all that in mind, the new Widget trait might look like:
trait Widget {
fn event(&mut self, ctx: &mut EventCtx, event: &Event, env: &Env);
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, env: &Env);
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, env: &Env) -> Size;
fn paint(&mut self, ctx: &mut PaintCtx, env: &Env);
fn children(&self) -> Vec<&dyn Widget>;
fn id(&self) -> Option<WidgetId>;
fn type_name(&self) -> &'static str;
}Overall, this is a very large, fundamental set of changes; it might become even larger if we try to build a common API with other frameworks. So I wouldn't be surprised if it took months or years to get to that point.
Ultimately, I can only speak for myself, as the author of a small Druid-based GUI library, and say that these are the changes that would help me and other people build a Druid frontend.