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!
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!
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!
Also, one could develop custom components very easily in Delphi with custom draw events which would draw just the component.
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...
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.
He does mention Redux in passing if you expand "Advanced topic: comparison with Elm"
[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?
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.
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?
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...
> 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).