Recently I have been running into some problems related to the current Druid architecture; in this post I would like to highlight two particular areas where Druid differs from reactive-style frameworks, and some of the problems that can arise around those differences.
My initial goal was for this post to be mostly just a sketch of the existing architecture, and then to focus on some of the challenges as a follow-up, but the challenges are foremost on my mind and so I’ve maybe glossed on doing a thorough explanation of what exists right now; in any case if that is something that folks think needs to be clarified, please let me know.
unmagic
The code you use to declare your UI in most reactive frameworks looks something like this (in pseudo Rust):
struct MyRootView {
count: usize,
}
impl ReactiveComponent for MyRootView {
fn body(&mut self) -> impl SomeView {
Column::new(&[
Label::new(format!("count is {}", self.count)),
Button::new("click_me").on_click(|| {
self.count += 1;
}),
])
}
}
It should be fairly clear to anyone who has written this sort of code what this is supposed to do; there will be a view containing a label above a button, and when you click the button the text of the label will change.
One important thing to note about this is that the code you write to describe your view is the same code that is used to update your view. This is where much of the complexity in those frameworks rests; there is some runtime behind the scenes that is maintaining a tree of widgets, responding to data mutations, diffing the tree that would result from those changes, doing layout, and generally balancing the books. I will refer to all of this background work, hidden from the user, as magic; it just happens.
The guiding philosophy of the current Druid architecture is that it is fundamentally non-magical. This is not a position we take out of some distaste for magic; it is just the trade-off that made the most sense to us, at the time. It is also a design choice that meshes will with Rust; one of the things that I personally find most compelling about Rust is that it is itself (for the most part) non-magical, with the code you write translating in a tractable way to the code that will be executed by the computer.
In any case, what this means is that in Druid there is a clear separation between creating and updating your widgets.
Druid has a simple mechanism for responding to state changes; as a general rule, all state that may be mutated by user action should be part of a top-level ‘data’ object. When events arrive, widgets are provided with mutable access to this data; and when an event has finished, the framework checks to see if the data has changed, and if it has widgets are given an opportunity to update their state, manually.
Indeed, in Druid, there is no widget tree that is distinct from the tree that you define when you create your application, and there is no diffing. If some widget only cares about a subset of the root data, it can be placed in a container widget that will manage extracting only that subset; but this is just another widget; there is nothing particularly special about it.
This ability to change behaviour by composing widgets is extremely flexible and conceptually simple, but it does make certain behaviour difficult to express.
Imagine for instance a view that shows an icon, and draws a border around that icon, where the border’s thickness is determined by a slider. In our imaginary reactive framework, that might look something like:
struct MyRootView {
border_width: f64,
}
impl ReactiveComponent for MyRootView {
fn body(&mut self) -> impl SomeView {
Column::new(&[
Icon::new("my_icon.png")
.border(self.border_width),
Slider::new(1.0..=40.0, |new_value| self.border_width = new_value),
])
}
}
In Druid…well, generously, there is more typing to do. The tricky part about
this is the .border(self.border_width)
part; this creates a new widget, that
wraps the Icon
widget. When the data changes, body
is called again, and
behind the scenes the framework sees that the returned widget has a different
border property, and goes and updates the underlying view.
In Druid, there is nothing to update but the actual widget graph itself. To
express this in current Druid, we would manually need to write a controller
widget to handle this case (a Controller
is a Druid convenience for writing a
widget that owns a single child)
/// A widget that rebuilds some child as needed, in response to data changes.
struct Rebuilder<T, W> {
/// A closure that takes the data and returns a bool indicating whether
/// the child should be rebuilt.
needs_rebuild: Box<dyn Fn(&T) -> bool>,
/// A closure that constructs a child from the data.
rebuild: Box<dyn Fn(&T) -> W>,
}
impl<T: Data, W: Widget<T>> Controller<T, W> for Rebuilder<T, W> {
// Called whenever the data changes
fn update(&mut self, child: &mut W, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) {
if !old_data.same(data) {
if (self.needs_rebuild)(data) {
*child = (self.rebuild)(data);
ctx.children_changed();
}
}
}
}
#[derive(Debug, Clone, Data, Lens)]
struct AppData {
border_width: f64,
}
fn build_ui() -> impl Widget<AppData> {
Flex::column()
.with_child(
Image::new("my_icon.png")
.border(10.0)
.controller(Rebuilder {
needs_rebuild: Box::new(|_| true),
rebuild: Box::new(|border_width| {
Image::new("my_icon.png").border(border_width)
}),
}),
)
.with_child(
Slider::new()
.with_range(1.0, 40.0)
.lens(AppData::border_width),
)
}
This isn’t too bad, but it does have some severe limitations; if for instance we wanted to put the border on the slider, we would be in trouble, because rebuilding the slider would discard important information (like that it is currently focused and receiving input) and so every time the slider changed, the drag gesture would be cancelled: not good.
Now we do have options; instead of always rebuilding the child, we could mutate
only the properties we care about, but this also has scaling problems; while
we could add a set_border_width
method to the Container
widget (the widget
that is implicitly created when we call .border(10.0)
on some other widget)
that would require us to have our controller know that its child was a
Container
, and we would need different controllers for different properties on
different types of widgets…it would end up being a lot of typing.
Data is everything
The single most important idea in Druid is the Data
trait. This trait is
very simple, but expresses a very powerful idea, and it has two basic
requirements:
- cheap cloning:
Data
values are expected to be cheap to clone (using reference-counted pointers in the general case) - fast approximate equality checking: it should be cheap to check whether two
instances of the same
Data
type may differ. This means that for collections we do not recursively check for the equality of all members; we merely check that the root pointer is identical. It is possible for two collections that contain identical data to fail this test, and that is fine.
In Druid, all widgets are parameterized over some type, T: Data
. This type
is passed to all of the widget’s methods.
For a slider, the T
is an f64
. When the slider changes, it is modifying this
data; and if the data changes externally, the slider updates itself
automatically. Similarly a checkbox’s data is a bool
, and a textbox’s data is a string.
Where does the Data
come from?
The simple idea that makes Druid work is that the data flows downwards. Any data that is part of a widget in one layer of the tree is passed in from the layer above; and while it is possible to do fancy things to add data in at some particular point in the graph, this is not particularly natural.
This ‘top-down’ approach has one major strength: it makes the logic for updating in response to changes very simple. As each widget’s data is some subset of its parent’s data, figuring out what needs updating after a change is easy; after the change, for each widget Druid just checks if the current data is different from the previous data, and if it does that widget is updated, and the process repeats; if the data is unchanged, update is skipped, and so is skipped for all children, since their data must also be unchanged.
This is a wonderfully simple mental model, but it does have some significant drawbacks.
Source of truth
Perhaps the biggest issue with the Data
approach is that it does not enforce
any notion of the single source of truth. All data lives at the top of the tree, and
in theory all data is mutably available anywhere else in the tree. To be robust,
a widget must be prepared to have its data mutate at any moment. More troubling,
data can mutate at unexpected times; a widget can not assume that the data it
sees when handling an event immediately after an update
call will be the same data
it saw in that update, because it is possible for the event to have already been
responded to by another widget earlier in the call, which may have mutated the
data.
By extension, ‘ownership’ (in the sense of responsibility, now in the sense of Rust’s ownership model) of data in Druid is purely enforced by convention; you know, in your app, who is going to be mutating the data, and this is largely okay; but it is still fairly easy to screw this up in ways that are not obvious, such as by having some controller widget change the data in response to some event, and having this violate some implicit expectation of its child.
A related problem is that some widgets really do just want to own their data. The clearest example of this to me is an editable text field; having the text be passed in to the editor on each event doesn’t really make sense here, because the editor has to maintain a bunch of internal state related to things like cursor positions, selections, linebreaks and the like. If the data changes externally (in some tricky case, like where the user is editing a contact in their address book, and a network sync happens to update the field they are editing) then this should be handled explicitly, not through some extremely cold code-path in the textbox implementation.
Not all state is really model data
This brings us to another point, which is that not all data is really part of
the model, and the conceptual idea behind the Data
trait is that your Data
is your model. The best example of this problem is the idea of a validation
error (which was the problem that prompted this post):
essentially, if you have a textbox that is editing data of some type (a date,
for instance) how do you report errors that occur when that data is invalid? It
feels silly to expect there to always be an Option<Error>
somewhere in the
root widget state, but this is really what Druid’s current architecture would
suggest.
Okay, so what
Overall I’ve been very impressed by the flexibility of Druid, especially given its relative simplicity. It’s also important to keep keep our goals clear; we are interested in building professional-quality tools, (like the font editor) and these sorts of tools are complicated and involve complicated tradeoffs, and they certainly will never look like the sample code you see in any UI framework, let alone a reactive one.
It does feel, however, like there is room for more exploration. One obvious
avenue here is crochet, an experiment at building a more traditional,
tree-mutation-based declarative API on top of Druid (although this is more as
a convenience, and less a desired end-state) and this shows a lot of promise. I
suspect there is lots else to consider exploring here as well; in particular I
am interested in many aspects of SwiftUI, particularly things like the
@ObservableObject
and @Published
property wrappers;
it feels like it should be at least possible to build something similar using
proc-macros, if we so desired.
The current architecture is around a year and a half old, and in that time we’ve had an opportunity to really see where the cracks are. The good news is that they aren’t chasms, but I also think we’ve learned a great deal about the overall design space in that time, and what feels most important is that we continue to be open to exploring it.