Xilem: An Architecture for UI in Rust
raphlinus.github.io
raphlinus.github.io
Here is why I'm hopeful. SwiftUI is as you say wonderful, but is very much a closed ecosystem. You can't really implement your own custom views, rather you can assemble the premade ones in various (cool and interesting!) ways. If you wanted grid layout before iOS 14, you were on your own.
By contrast, in what I'm building, everything is open-ended, and you are invited to build fully custom versions of every piece of the system - change propagation, async resource loading, layout, drawing, animation, everything.
So yes, I am hopeful this approach will give good results for especially those highly intensive applications you mention. And if not, we'll learn something why not. I'm looking forward to trying!
I’m curious to see how you’re handling events. Best of luck!
SwiftUI takes this approach, as ComponentKit did before it. The framework still does most everything (state updates, layout, animation, etc), but the host compositor handles the actual rendering.
This approach has a few advantages in my opinion. These widgets integrate nicely with the host accessibility system, for one. They also can come with built-in styling to make them “fit into” the target platform.
There are also performance benefits to leveraging the system compositor – Firefox switched to have platform native layers on macOS and it helped immensely with battery life.
It is worth noting that SwiftUI and ComponentKit components don’t have 1:1 mappings with native widgets – they both perform flattening (i.e. for drawing paths and layout only nodes) to optimize their performance.
I think most importantly, though, it allows for rewriting code incrementally as that approach naturally supports bidirectional embedding. Having rewritten the Shortcuts app editor from UIKit into ComponentKit into SwiftUI (maybe one of the larger projects written in SwiftUI?), incremental rewriting is crucial for existing software projects.
The other concern is mobile – it just is not feasible to rewrite the text input stack on mobile, for example, so being able to use native text editing views would very much be necessary.
This all looks fantastic!
Also, one could develop custom components very easily in Delphi with custom draw events which would draw just the component.
But yes, you can't add comments and yes, the .dfm file which is generated is difficult to diff.
A somewhat galaxy-brain approach to this is to make views generic over Cx, and add "factory" methods to Cx for creating button, stack, slider, etc.
All that said, my personal feeling is that while this would give promising early results for creating simple property-sheet like UI, it will be extremely difficult to make polished, truly native-feeling UI in it, as ultimately the seams will show. The first 90% will go well, but the second 90% will be painful.
I don't want to discourage people from trying it though!
Question for the author: perhaps I missed it, but how do you plan to handle view trees that change based on state (ie SwiftUI’s IfElseView + viewbuilder)?
if_view(bool_predicate, || view1(...then...), || view2(...else...))
Whether we end up having a proc macro that has similar functionality as ViewBuilder in SwiftUI is an open question. For the time being, I'm seeing how far I can get with just vanilla Rust.I'm generally pretty hopeful about the ability of the Rust compiler to handle big complex types, but it is a risk. There other projects out there that also stress it, and the compiler team is pretty serious about making this work well.
> ... Architectures that work well in other languages generally don’t adapt well to Rust, mostly because they rely on shared mutable state and that is not idiomatic Rust, to put it mildly. ...
The author doesn't mention Redux (the architecture), which is surprising. There are three principles[1]:
1. The global state of your application is stored in an object tree within a single store.
2. The only way to change the state is to emit an action, an object describing what happened.
3. Changes are made with pure functions.
In other words, the components of an application never mutate the state tree directly. Rather, they emit actions which re-generates the state tree without mutation.
This style of state management is compatible with Rust's ownership model.[2] The emphasis on pure functions (that clone state rather than mutate) means that it's not necessary for your application to alias mutable references, which I'm guessing underlies the "generally don't adapt well to Rust" part of the claim.
[1] https://redux.js.org/understanding/thinking-in-redux/three-p...
He does mention Redux in passing if you expand "Advanced topic: comparison with Elm"
I'll also say this: the tools that Rust provides for reasoning about mutation are powerful and principled. A central philosophy of Rust is that mutation isn't the problem, it's shared mutable state. If you believe that philosophy (and I do), then restricting yourself to pure functions over clonable state feels like tying one hand behind your back. I hope I've made the case that providing finer grained access to mutable app state is an approach at least worth exploring.
[1]: https://redux.js.org/understanding/history-and-design/prior-...
edit: the potential for Python bindings is very interesting, it seems to me that Python and Rust are developing a sweet kinship :,)
[1]: https://github.com/janestreet/incr_dom | https://www.youtube.com/watch?v=R3xX37RGJKE
[2]: https://github.com/janestreet/bonsai/blob/master/docs/blogs/...
A semi-explicit goal of this work (that somehow didn't make it into the blog post) is that developing for this architecture, both building the UI components and using them, should be a lot more fun than before. Of course, that's a slippery goal to quantify. We'll just have to see how it goes, but I'm hopeful.
I don't see the problem with using interior mutability (Rc<RefCell<T>> or Arc<Mutex<T>>)
With Slint [1], we just embrace it, and rely on interior mutability for the shared state, and that works well.
let text = self.text.clone();
focus(move |has_focus| {
let text = text.clone();
state(TextEditorState::new(), move |state| {
let text = text.clone();
let text2 = text.clone();
let cursor = state.with(|s| s.cursor);
let state2 = state.clone();
currently looks like this: focus(move |has_focus| {
state(TextEditorState::new, move |state, cx| {
let cursor = cx[state].cursor;
canvas(move |cx, rect, vger| {
(Note the context (cx) passed to callbacks to look things up.)The simple way to do this is a callback system. Why is that not appropriate for Rust? Does it require custom ownership dynamics that the borrow checker does not support?
This is the "interior mutability" approach that the article mentions.
I think a lot of the problem is really with the confusingness of the concept of static lifetime in rust to begin with, where it's kinda used for both "lives forever" and "doesn't reference anything that lives longer than it"[1]. I hope someday these meanings get different names, tbh.
But when you see that error the naive thought is like... "ok better make it live forever" but it really just means you need to make it something that you control, and there are other ways to do that than a refcounting mutex.
[1] https://doc.rust-lang.org/rust-by-example/scope/lifetime/sta...
It’s like the classic Joe Armstrong quite about getting the whole jungle when all you wanted is the banana.
You can do it, and if you check out the Gtk/QT bindings you’ll see the boilerplate it introduces. Not insurmountable but many people are interested in figuring out if there’s a better way.
There is a workaround called "internal mutability", an ability to mutate state pointed by a shared pointer. That is syntactically slightly messier, and generally frowned upon. The blog post mentions about this workaround, and also states that it's not ideal so we should strive for better.
I don’t see why this would be frowned upon. It seems to be a runtime version of the borrow checker. For sufficiently dynamic code I don’t see how you can get around checking mutability access at runtime (or asserting that multiple blocks of code aren’t concurrently requesting a mutable reference).
As a comparison, there are many cases where the compiler can prove that a bounds check is unnecessary for accessing an element in a vector but there are also many useful cases where it’s simply not possible to do at compile time. It would be silly to frown upon runtime bounds checks when the requested index or the size of the vector is not known at compile time, a common occurrence in many interesting programs.
I get the motivation to make APIs as statically checkable as possible but it doesn’t seem to always be practical. Reusable UI components can be used in a variety of contexts, e.g. situations with multiple callbacks for different backends. The information is just not always there at compile time.
I don’t think there is a way around this. You’re dealing with a high-level runtime condition. There are no tractable ways to get compilers to understand these higher level runtime conditions, so conditions must be reproven at runtime. It needs an oracle. That oracle should be you but you’ve decided that you cannot be trusted. This appears to be somewhat of a contradiction. Should you even be writing this program in the first place?
Interpolations of anything beyond basic properties such as size, orientation, opacity, color, stroke, and fill; vector shapes, particle systems, and generative textures come to mind.
There are intermediate possibilities. In theory you could "compile" the animation of a skeletal system with inverse kinematics to CSS, but I'm not aware of anyone going to the trouble.
In some cases you could get a good-enough result by animating from arbitrary (ie. user controlled) starting point to one or more fixed end-states (eg. Three "flick right" animations, one each for flick up and to the right, right, or down and to the right), but at that point you might as well just use an animation library.
The ones you don't notice are most often good - yet they are there. You'll notice degrading UX when they aren't. The ones you notice are quite likely bad, but that doesn't make the majority of aniation bad.
Anything, from a blinking cursor, to the highlight effect on your smartphone keyboard to the check-uncheck of a checkbox is animated. Subtle, but crucial.
So while it is technically an animation, I don't think it really requires any special GUI toolkit support to implement.
I'm not necessarily saying they don't matter, more that I'd like to find out how much they actually matter, vs how much people think they matter.
My two go-to examples:
- The shopping app I use has some kind of points system and some items give you "boost". I hate the concept and don't even care what it does because adding those items animates a bar filling up and take extra 4s while I'm trying to do this as quickly as possible. This is a terrible use of animations.
- Any app where you can rearrange a list needs an animation for when you hold an item and try to move it. Specifically, other entries shouldn't instantaneously jump around when you cross some threshold. There's lots of subtle ways to achieve this, but one of my favorites is greying out the old space and springing items up/down as you move the chosen one around. This animation is almost required, because the experience is jarring otherwise.
It tends to be. The only (popular) JS framework where it's a first class concept is with Svelte. There's a basic set of transitions and the framework handles the post-out-transition removal (which is annoying in a lot of frameworks) but there's also custom CSS animations where a JS-defined curve is rendered into CSS [1], semi-automated FLIP animations for non-transitioning nodes [2], and deferred transitions where the outro of one element is sync'd with the intro of another [3]. There's a few other things like springs and whatnot but I consider the package as a whole to be a significant advancement in the state of the art.
[1] https://svelte.dev/tutorial/custom-css-transitions [2] https://svelte.dev/tutorial/animate [3] https://svelte.dev/tutorial/deferred-transitions
The only other framework I know of that's tackled it in core is Inferno but I haven't read through their implementation in detail.
One thing I think might be problematic for you would be fallibility in the `Adapt` callback process. i.e. if it would not be sound to change the state of a child due to some emerging app state inconsistency or similar.
To put it differently, I understand your architecture outputs a new _statically typed_ tree every time through kind of recursive state mutation. However, being statically typed, it is clear from the start of the operation what the end complete static type will be (to the compiler at least). If the transformation of one of the children is not possible (although the rest might be fine), what do you do?
The obvious way would be to make each conversion fallible, but this might be a pain to use/propagate. Otherwise, you might have state that all operations involved in state mutation must be infallible.
Anyway just my 2c. I might have misunderstood the mechanism, though.
Thank you for all your impressive work!
https://github.com/chaosprint/glicol/tree/main/rs/synth
This might be interesting for you as I found that you also have a synth project (https://github.com/raphlinus/synthesizer-io). Admittedly, there is still some way to go for this audio lib. Will further study this post when I got more spare time.
Referenced in this article are: Xi-Editor, discussed in Xi-Editor Retrospective[2] and Druid, discussed in Rust 2021: GUI[3], which follows closely after Principled Reactive UI[4] (describing a prototype for Druid called Crochet).
> I have long believed that it is possible to find an architecture for UI well suited to implementation in Rust, but my previous attempts (including the current Druid architecture) have all been flawed.
These have all been very very good & technical posts on what toolkits really support & enable ui (amid other great technical topics too). It's delightful seeing such an ongoing continuation, an evolcing refinememt of ideas & self-review from someone of such expert caliber.
Rarely do we get such an intimate view into what the real hunt for rightness is, see how we ever hunt for perfection. Personally I believe that hunt for better is one of the undertold aspects of hackerdom, a less visible less knowable reciprocal to fast-and-dirty. Tapping & enabling this creative, knowledge & intellect based capability is a core spring from where greatness emerges.
> I have studied a range of other Rust UI projects and don’t feel that any of those have suitable architecture either.
It's also notable how widely raphlinus travels to get the best persepctive available. This post begins with a a vast field survey of other ui libraries & their origins. I lack the energy to search down & link HN discussions on each of these, for there are many! But seeing how everyone else is doing, looking wide & far to explore their peer's attempts, is also a notable characteristic I admire here.
[1] https://news.ycombinator.com/from?site=raphlinus.github.io
[2] https://raphlinus.github.io/xi/2020/06/27/xi-retrospective.h... https://news.ycombinator.com/item?id=23663878 (538 points, 26 months ago, 157 points)
[3] https://raphlinus.github.io/rust/druid/2020/09/28/rust-2021.... https://news.ycombinator.com/item?id=24631611 (374 points, 31 months ago, 244 comments
[4] https://raphlinus.github.io/rust/druid/2020/09/25/principled... https://news.ycombinator.com/item?id=24599560 (234 points, 31 months ago, 97 comments)
That would be restricting of course when one needs features available in only one backend and developers could opt-in for more control and require a certain backend like Gtk or not require but detect the backend and get extra features.
I wonder what happens regarding state (say, selection state, or visibility/enabled state) in the case where you might want to allow the user to re-arrange entire UI components (say the user draging a nested tab/pane from one window of the app to another, and docking it into another different hierarchy): would the trees have to be completely re-built (I guess sub treelets could still be kept?) along with re-building the id paths. Would that mean diffing is hard/impossible in some cases to transfer across a large re-build of these trees?
The second question is more challenging (see the "advanced topic" under identity for a little more background). View id's cannot be re-parented, in other words when a parent relationship is expressed in an id path (by virtue of having the child id follow the parent in a path), that relationship cannot be changed. However, widget ids and view ids are not necessarily the same, though they can be. I think what's needed for your use case is a level of indirection so the view id paths remain stable, but the widget id structure relationships can be changed. I haven't worked out all the details, but think it can be done, and if it's done right it wouldn't require any rebuilding of widget subtrees.
What's the reason for replicating the "React idea" outside the web browser?
The way I understand the motivation behind React is that it tries to work around the too high level and too rigid DOM by mapping one way to describe an UI (the React API) to another (the DOM), for the only reason that there's no realistic way to bypass the DOM (except doing everything yourself - including fundamentals like text rendering - via a 2D- or WebGL canvas).
But if you don't have something as rigid as the DOM as lowest layer to begin with, what's the point of building a React-style system with 'tree diffing' against data that persists across frames? Is it really worth the complexity managing intermediate data that sits between the UI description that (most likely) needs to be updated each frame, and the low-level rendering instruction stream - which most likely also needs to be generated each frame?
Or is it all about Rust's language restrictions?
Haven't really thought it over fully, but so far it sounds really nice.
Cannot really say if Svelte is worse than React, as I have much more experience with React itself. My main blocker to adopt it (Svelte) is mostly library support - with React one can use "everything" in all kinds of "hackish" ways and with TypeScript, which wasn't really the case with Svelte last I checked, maybe 2 years ago.
The trick with Svelte is that “two domains” are very, very, close to each other. A JavaScript programmer can learn and understand Svelte after 2 days tutorials.
Library support is something that can be improved only over time.
For that matter, are you next going to extend this simple exploration toward more complexity (eg. more widget types, more interactions, more layout constraints, etc.) or toward more data?
As I've followed your efforts in this area, the power and utility of selecting the right motivating example has been coming into focus. The parallels with a startup's MVP are fairly clear, the parallels to selecting a (dissertation) research topic a bit handwavy (in part because guidelines for the latter are extraordinarily vague). So far you haven't really chosen a new motivating end-user application since suspending development of Xi. There are lots of directions you could go, from making an equivalent to Processing, to creating a game engine, or eventually restarting Xi development with an eye toward exploration and visualization of large codebases, etc. Personally I'm starting to become intrigued by the need to dig into large neural nets and the representations encoded within (related subtopics: latent spaces, feature vectors, orthogonality):
https://distill.pub/2019/activation-atlas/
Meanwhile, I've been casting about for a proper (Rust) motivating example app/library of my own, and the closest I've come so far is something like a HTML + CSS rendering engine, though not targeted at the Web per-se, but instead at ebooks (esp. EPUB), a much smaller set of required functionality.
In theory a desktop ebook library + reader app with features like MathML support, advanced typography, and visualizing connections between millions of works and annotations seems like it would be a better fit with the direction you're (currently) going than any existing framework.
One small typo:
> {anonymous function of type FnMut(u32) -> ()}
It looks like the param type should be `&mut u32`. And in that simple case the whole thing could probably just be `fn(&mut u32)` since the closure doesn't capture any locals.
Is there any concern with the allocation for the view/widget tree every cycle? I know it's retained mode so the most important resources (graphics) are cached between cycles, but I'm wondering if the trees ought to be allocated out of arenas or something so that the allocating/freeing every cycle isn't undue performance penalty.
(I'm not sure what existing regained frameworks do.)
adapt(
|data: &mut Arc<Parent>, thunk| {
let mut child = data.child.clone();
thunk.call(&mut child), // <-- HERE
if !Arc::ptr_eq(&data.child, &child) {
Arc::make_mut(data).child = child;
}
},
child_view(...) // which has Arc<Child> as its app data type
)
Maybe it was meant to be a semicolon?* How Glimmer.js implemented autotracking: https://www.pzuraq.com/blog/how-autotracking-works
* Discussion specifically on Lamport Clocks and autotracking: https://v5.chriskrycho.com/journal/autotracking-elegant-dx-v...
Here's my current thinking on the topic. It all depends on whether you want to model your problem as a tree or a graph (see also [1] for some great discussion of that tension). If you model your problem as a graph, then a general purpose incremental computation engine like Adapton (or Incremental or Salsa) is great. However, if your problem is a tree, then constructing the explicit dependency graph requires a nontrivial amount of ceremony. What Xilem does is represent the most common tree-structured flows of information in a very lightweight manner (mostly just plain code that builds views), while allowing you to insert arbitrary graph edges if you like with more work.
I think it's a discussion worth continuing. One thing you could do is integrate Adapton and Xilem together, where the implementation of most view nodes in the latter becomes queries into the Adapton engine. How well would that work? Really only one way to find out.
[1]: https://glazkov.com/2022/02/06/tension-between-graphs-and-tr...
> The name “Xilem” is derived from xylem, a type of transport tissue in vascular plants, including trees. The word is spelled with an “i” in several languages including Romanian and Malay, and is a reference to xi-editor, a starting place for explorations into UI in Rust (now on hold).
"As is completely standard in declarative UI, it is done by diffing the old view tree against the new one, in this case calling the rebuild method on the View trait. This method compares the data, updates the associated widget if there are any changes, and also traverses into children. The view tree is retained just long enough to do event propagation and to be diffed against the next iteration of the view tree, at which point it is dropped. At any point in time, there are at most two copies of the view tree in existence."
Throwing away some internal state is not relevant to this distinction.