Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Xilem architecture prototype #2183

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9f06dda
Initial commit of idiopath experiment
raphlinus Apr 12, 2022
1caa8ca
Add AnyView
raphlinus Apr 12, 2022
899feb2
Add more widget tuples
raphlinus Apr 13, 2022
fc142ea
Implement not very useful form of use_state
raphlinus Apr 13, 2022
08afce9
A slightly more useful version
raphlinus Apr 13, 2022
8a4b687
Add README
raphlinus Apr 13, 2022
f30b2bb
Update README.md
richard-uk1 Apr 16, 2022
52c29e8
Silence clippy. (#2169)
jneem Apr 21, 2022
677f13f
X11 dialogs, take 2. (#2153)
jneem Apr 22, 2022
ecbcb0f
Fixed readme.md links (#2167)
dakata1337 Apr 22, 2022
cfa7380
Introduce Cx for reconciliation
raphlinus Apr 15, 2022
bb86d0f
Minor ergonomic improvements
raphlinus May 3, 2022
c607825
Return "changed" indication from rebuild
raphlinus May 3, 2022
a69bb1b
Add EventResult
raphlinus May 3, 2022
8682d90
Start implementing a widget hierarchy
raphlinus May 4, 2022
a2acd92
Start implementing layout
raphlinus May 11, 2022
3e703fb
Fix alignment min/max aggregation
raphlinus May 13, 2022
163f756
Use trait rather than enum for alignments
raphlinus May 13, 2022
ebe51a6
Continued work on alignment
raphlinus May 13, 2022
064d639
Use single Center alignment for horiz and vert
raphlinus May 14, 2022
a1706b8
Add context to event method
raphlinus May 14, 2022
e678cc4
Implement layout observer
raphlinus May 14, 2022
c24b40c
Merge branch 'master' into idiopath
raphlinus May 18, 2022
6c6b0f2
A bit of reorganization
raphlinus May 18, 2022
5aaec6d
Make into library
raphlinus May 18, 2022
ab39d1f
Rename to xilem
raphlinus May 19, 2022
c4171f5
Start building event propagation
raphlinus May 19, 2022
19aa2e6
Add lifecycle method
raphlinus May 20, 2022
a6fb0b8
Basic button functionality
raphlinus May 20, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ members = [
"druid/examples/web",
"druid/examples/hello_web",
"druid/examples/value_formatting",
"xilem",
]
default-members = [
"druid",
Expand Down
10 changes: 10 additions & 0 deletions xilem/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "xilem"
version = "0.1.0"
license = "Apache-2.0"
authors = ["Raph Levien <[email protected]>"]
edition = "2021"

[dependencies]
"druid-shell" = { path = "../druid-shell" }
bitflags = "1.3.2"
80 changes: 80 additions & 0 deletions xilem/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# An experimental Rust architecture for reactive UI

Note: this README is a bit out of date. To understand more of what's going on, please read the blog post, [Xilem: an architecture for UI in Rust].

This repo contains an experimental architecture, implemented with a toy UI. At a very high level, it combines ideas from Flutter, SwiftUI, and Elm. Like all of these, it uses lightweight view objects, diffing them to provide minimal updates to a retained UI. Like SwiftUI, it is strongly typed.

## Overall program flow

Like Elm, the app logic contains *centralized state.* On each cycle (meaning, roughly, on each high-level UI interaction such as a button click), the framework calls a closure, giving it mutable access to the app state, and the return value is a *view tree.* This view tree is fairly short-lived; it is used to render the UI, possibly dispatch some events, and be used as a reference for *diffing* by the next cycle, at which point it is dropped.

We'll use the standard counter example. Here the state is a single integer, and the view tree is a column containing two buttons.

```rust
fn app_logic(data: &mut u32) -> impl View<u32, (), Element = impl Widget> {
Column::new((
Button::new(format!("count: {}", data), |data| *data += 1),
Button::new("reset", |data| *data = 0),
))
}
```

These are all just vanilla data structures. The next step is diffing or reconciling against a previous version, now a standard technique. The result is an *element tree.* Each node type in the view tree has a corresponding element as an associated type. The `build` method on a view node creates the element, and the `rebuild` method diffs against the previous version (for example, if the string changes) and updates the element. There's also an associated state tree, not actually needed in this simple example, but would be used for memoization.

The closures are the interesting part. When they're run, they take a mutable reference to the app data.

## Components

A major goal is to support React-like components, where modules that build UI for some fragment of the overall app state are composed together.

```rust
struct AppData {
count: u32,
}

fn count_button(count: u32) -> impl View<u32, (), Element = impl Widget> {
Button::new(format!("count: {}", count), |data| *data += 1)
}

fn app_logic(data: &mut AppData) -> impl View<AppData, (), Element = impl Widget> {
Adapt::new(|data: &mut AppData, thunk| thunk.call(&mut data.count),
count_button(data.count))
}
```

This adapt node is very similar to a lens (quite familiar to existing Druid users), and is also very similar to the [Html.map] node in Elm. Note that in this case the data presented to the child component to render, and the mutable app state available in callbacks is the same, but that is not necessarily the case.

## Memoization

In the simplest case, the app builds the entire view tree, which is diffed against the previous tree, only to find that most of it hasn't changed.

When a subtree is a pure function of some data, as is the case for the button above, it makes sense to *memoize.* The data is compared to the previous version, and only when it's changed is the view tree build. The signature of the memoize node is nearly identical to [Html.lazy] in Elm:

```rust
fn app_logic(data: &mut AppData) -> impl View<AppData, (), Element = impl Widget> {
Memoize::new(data.count, |count| {
Button::new(format!("count: {}", count), |data: &mut AppData| {
data.count += 1
})
}),
}
```

The current code uses a `PartialEq` bound, but in practice I think it might be much more useful to use pointer equality on `Rc` and `Arc`.

The combination of memoization with pointer equality and an adapt node that calls [Rc::make_mut] on the parent type is actually a powerful form of change tracking, similar in scope to Adapton, self-adjusting computation, or the types of binding objects used in SwiftUI. If a piece of data is rendered in two different places, it automatically propagates the change to both of those, without having to do any explicit management of the dependency graph.

I anticipate it will also be possible to do dirty tracking manually - the app logic can set a dirty flag when a subtree needs re-rendering.

## Optional type erasure

By default, view nodes are strongly typed. The type of a container includes the types of its children (through the `ViewTuple` trait), so for a large tree the type can become quite large. In addition, such types don't make for easy dynamic reconfiguration of the UI. SwiftUI has exactly this issue, and provides [AnyView] as the solution. Ours is more or less identical.

The type erasure of View nodes is not an easy trick, as the trait has two associated types and the `rebuild` method takes the previous view as a `&Self` typed parameter. Nonetheless, it is possible. (As far as I know, Olivier Faure was the first to demonstrate this technique, in [Panoramix], but I'm happy to be further enlightened)

[Html.lazy]: https://guide.elm-lang.org/optimization/lazy.html
[Html map]: https://package.elm-lang.org/packages/elm/html/latest/Html#map
[Rc::make_mut]: https://doc.rust-lang.org/std/rc/struct.Rc.html#method.make_mut
[AnyView]: https://developer.apple.com/documentation/swiftui/anyview
[Panoramix]: https://github.com/PoignardAzur/panoramix
[Xilem: an architecture for UI in Rust]: https://raphlinus.github.io/rust/gui/2022/05/07/ui-architecture.html
46 changes: 46 additions & 0 deletions xilem/examples/counter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2022 The Druid Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use xilem::{button, v_stack, Adapt, App, AppLauncher, LayoutObserver, Memoize, View};

#[derive(Default)]
struct AppData {
count: u32,
}

fn count_button(count: u32) -> impl View<u32> {
button(format!("count: {}", count), |data| *data += 1)
}

fn app_logic(data: &mut AppData) -> impl View<AppData> {
v_stack((
format!("count: {}", data.count),
button("reset", |data: &mut AppData| data.count = 0),
Memoize::new(data.count, |count| {
button(format!("count: {}", count), |data: &mut AppData| {
data.count += 1
})
}),
Adapt::new(
|data: &mut AppData, thunk| thunk.call(&mut data.count),
count_button(data.count),
),
LayoutObserver::new(|size| format!("size: {:?}", size)),
))
}

pub fn main() {
let app = App::new(AppData::default(), app_logic);
AppLauncher::new(app).run();
}
148 changes: 148 additions & 0 deletions xilem/src/app.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright 2022 The Druid Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use druid_shell::kurbo::Size;
use druid_shell::piet::{Color, Piet, RenderContext};
use druid_shell::WindowHandle;

use crate::widget::{CxState, EventCx, LayoutCx, PaintCx, Pod, UpdateCx, WidgetState};
use crate::{
event::Event,
id::Id,
view::{Cx, View},
widget::{RawEvent, Widget},
};

pub struct App<T, V: View<T>, F: FnMut(&mut T) -> V> {
data: T,
app_logic: F,
view: Option<V>,
id: Option<Id>,
state: Option<V::State>,
events: Vec<Event>,
window_handle: WindowHandle,
root_state: WidgetState,
root_pod: Option<Pod>,
size: Size,
cx: Cx,
}

const BG_COLOR: Color = Color::rgb8(0x27, 0x28, 0x22);

impl<T, V: View<T>, F: FnMut(&mut T) -> V> App<T, V, F>
where
V::Element: Widget + 'static,
{
pub fn new(data: T, app_logic: F) -> Self {
let cx = Cx::new();
App {
data,
app_logic,
view: None,
id: None,
state: None,
root_pod: None,
events: Vec::new(),
window_handle: Default::default(),
root_state: Default::default(),
size: Default::default(),
cx,
}
}

pub fn ensure_app(&mut self) {
if self.view.is_none() {
let view = (self.app_logic)(&mut self.data);
let (id, state, element) = view.build(&mut self.cx);
let root_pod = Pod::new(element);
self.view = Some(view);
self.id = Some(id);
self.state = Some(state);
self.root_pod = Some(root_pod);
}
}

pub fn connect(&mut self, window_handle: WindowHandle) {
self.window_handle = window_handle.clone();
// This will be needed for wiring up async but is a stub for now.
//self.cx.set_handle(window_handle.get_idle_handle());
}

pub fn size(&mut self, size: Size) {
self.size = size;
}

pub fn paint(&mut self, piet: &mut Piet) {
let rect = self.size.to_rect();
piet.fill(rect, &BG_COLOR);

self.ensure_app();
loop {
let root_pod = self.root_pod.as_mut().unwrap();
let mut cx_state = CxState::new(&self.window_handle, &mut self.events);
let mut update_cx = UpdateCx::new(&mut cx_state, &mut self.root_state);
root_pod.update(&mut update_cx);
let mut layout_cx = LayoutCx::new(&mut cx_state, &mut self.root_state);
root_pod.prelayout(&mut layout_cx);
let proposed_size = self.size;
root_pod.layout(&mut layout_cx, proposed_size);
if cx_state.has_events() {
// We might want some debugging here if the number of iterations
// becomes extreme.
self.run_app_logic();
} else {
let mut paint_cx = PaintCx::new(&mut cx_state, &mut self.root_state, piet);
root_pod.paint(&mut paint_cx);
break;
}
}
}

pub fn window_event(&mut self, event: RawEvent) {
self.ensure_app();
let root_pod = self.root_pod.as_mut().unwrap();
let mut cx_state = CxState::new(&self.window_handle, &mut self.events);
let mut event_cx = EventCx::new(&mut cx_state, &mut self.root_state);
root_pod.event(&mut event_cx, &event);
self.run_app_logic();
}

pub fn run_app_logic(&mut self) {
for event in self.events.drain(..) {
let id_path = &event.id_path[1..];
self.view.as_ref().unwrap().event(
id_path,
self.state.as_mut().unwrap(),
event.body,
&mut self.data,
);
}
// Re-rendering should be more lazy.
let view = (self.app_logic)(&mut self.data);
if let Some(element) = self.root_pod.as_mut().unwrap().downcast_mut() {
let changed = view.rebuild(
&mut self.cx,
self.view.as_ref().unwrap(),
self.id.as_mut().unwrap(),
self.state.as_mut().unwrap(),
element,
);
if changed {
self.root_pod.as_mut().unwrap().request_update();
}
assert!(self.cx.is_empty(), "id path imbalance on rebuild");
}
self.view = Some(view);
}
}
Loading