Sam Sartor
Git( Hub| Lab)
# Trees Aren't All You Need 2023-2-7

Part 1 | -> Part 2 | Part 3 | …


In the last post we discussed different ways to build a simple GUI application, consisting of a list of counters. In any case, the goal is to manage the view/model state across updates in a way that is minimally painful. Although immediate mode is very ergonomic for simple GUIs, it has trouble scaling up. Retained-mode is more flexible, but it is absolutely awful to work with, especially in Rust. It seems like a hybrid approach, using widget trees is the way to go.

42 12 7
#[widget]
fn counter() -> Element {
    let mut color = use_state(new_random_color);
    let mut count = use_state(|| 0);
    view! {
        text: count,
        style: format!("border: 2px solid {color};"),
        button { text: "+", onclick: count += 1 },
        button { text: "-", onclick: count -= 1 },
    }
}

#[widget]
fn app() -> Element {
    let mut counter_ids = use_state(Vec::new);
    view! {
        for &id in counter_ids {
            counter { key: id }
        },
        button { text: "+", onclick: counter_ids.push(Uuid::new_v4()) },
    }
}

Even benchmarking across a larger set of applications (such as 7GUIs), this approach holds up pretty well. But there is a point where it starts to break down. For example, imagine we add a total to the counter list:

42 12 7 61 Total

Thinking in the abstract, there is no reason at all why a total box should make anything more difficult. When one of the counts changes, we already need it to propagate into the code that puts pixels on the screen. Why couldn’t it automatically propagate into the code that computes the total?

The problem is that we’ve broken our nice tree structure. Previously our application’s reference graph looked like this:

app Application c1 Widget #1 app->c1 c2 Widget #2 app->c2 c3 Widget #3 app->c3 i1 Count=42 c1->i1 i2 Count=12 c2->i2 i3 Count=7 c3->i3

Because each object in the view/model state had a single unique owner, it was easy to schedule mutable updates and recover references afterwards. When an event is received, immediately recompute each relevant widget’s view state, then diff them to see what children are still attached to their parents. But with the added summation, our reference graph is no longer a simple tree. At minimum it becomes a directed acyclic graph, possibly it even has cycles.

Direct Reference:
app Application c1 Widget #1 app->c1 c2 Widget #2 app->c2 c3 Widget #3 app->c3 i1 Count=42 c1->i1 i2 Count=12 c2->i2 i3 Count=7 c3->i3 i2->app i1->app i3->app
Separate Property:
app Application c1 Widget #1 app->c1 c2 Widget #2 app->c2 c3 Widget #3 app->c3 sum Total=61 app->sum i1 Count=42 c1->i1 i2 Count=12 c2->i2 i3 Count=7 c3->i3 i2->sum i1->sum i3->sum
Separate Model State:
cluster1 cluster2 app Application i2 Count=12 app->i2 i1 Count=42 app->i1 i3 Count=7 app->i3 sum Total=61 i2->sum i1->sum i3->sum root Root Widget root->app root->sum c1 Widget #1 root->c1 c2 Widget #2 root->c2 c3 Widget #3 root->c3 c1->i1 c2->i2 c3->i3

This is why a pure widget tree really gums up complex applications. In our case it assumes that each counter element can hold ownership of its own model state (the count) while it is tossed back and forth between the input side (event handlers) and output side (child widget construction). No other code is allowed access. But now the counts also have to be tossed over to a summation function somewhere. We could possibly add another stage to the loop, in the style of an entity-component-system, but I don’t think it is the right approach for a general GUI framework.

Wait for Event Layout and Paint Pass to Output Pass to Summation? Owns the State Owns the State Owns the State Recover References Update State Pass to Input Sum the Counters?

Instead we should simply yank the counts out of their separate widgets and include them in the model state of the root widget, so it has complete access. Then whenever the root widget updates a count, it can pass the new state down into each child counter via widget parameters.

#[widget]
fn counter(count: usize, onset: impl Fn(i32)) -> Element {
    let mut color = use_state(new_random_color);
    view! {
        format!("{count}"),
        style: format!("border: 2px solid {color};"),
        button { text: "+", onclick: onset(count + 1) },
        button { text: "-", onclick: onset(count - 1) },
    }
}

#[widget]
fn app() -> Element {
    let mut counter_ids = use_state(Vec::new);
    let total: i32 = counter_ids.iter().copied().sum();
    view! {
        for (index, count) in counter_ids.iter().copied().enumerate() {
            counter {
                key: index,
                count: count,
                onset: move |value| counter_ids[index] = value,
            }
        },
        button { text: "+", onclick: counter_ids.push(0) },
        format!("Total: {total}"),
    }
}

Passing state and callbacks downward via parameters is a trick familiar to any React programmer. But when taken too far, your widget tree turns into a sort of immediate-mode-with-extra-steps (at least in languages with side effects, sorry Elm). As UX tweaks and feature developments move information around inside the interface, developers will be forced to push more and more state up into the root until there really isn’t any point keeping model state tied to particular elements at all. Generally this is the motivation for a Redux-style separation where all model state gets moved into its own subsystem, but then we are right back to the previous problem we faced in part 1: how can Rust programers state share model state between the input and output sides of the app while also modifying it at will?

I personally like Bender’s solution: the RNA state management framework. Given the amount of customization available in Blender’s user interface, you can make the same data pop up over and over again in lots of places. In that sense, the Blender GUI is absolutely not a simple tree. For example, see how many times you can pick out the “Boards - Basketweave” material in this file demoing the new asset library system:

Like the counts in our counter list, the names of Blender materials are globally accessible to many elements of the GUI. If “Boards - Basketweave” is renamed anywhere, the displayed text needs to update everywhere. And don’t forget, any such change also needs to be registered with the undo/redo stack!

Blender is extra crazy because all of the model state is also accessible via a Python scripting interface. If the name is changed via the Python interface, it needs to propagate just as if the user changed it through the GUI.

Even crazier than that, Blender allows the artist to use a simple Python expression called a “driver” in place of a real value in virtually any input field, so that the value of the field updates dynamically depending on other changes in the scene.

Like any long-running project, Blender has developed a few different ways of keeping all this data up-to-date, but RNA is the most modern (and is the one I personally understand). All of the key objects in a Blender scene are defined as RNA structures, with various properties. Most properties appear as simple integers, floats, strings, booleans, references, or collections. But each property indirectly tracks of a whole bunch of extra metadata besides. Not just past values for the undo/redo stack, but also functions to call on change, minimum and maximum values to use for validation in the GUI, even textual descriptions. Critically, all this metadata allows properties to be exposed directly to the user interface. The python code to add such a menu is disgustingly trivial:

class BLOG_PT_some_panel(bpy.types.Panel):
    bl_label = 'My Menu'
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'

    def draw(self, context):
        col = self.layout.column()
        col.prop(context.scene.some_object, 'some_property')

When you save your project, all the RNA objects with live references get serialized out to a file. And even that reference count is itself exposed to the user via Data API panel of the Blender GUI:

Wrapping up my digression into blender, we return to the point I am trying to make in part 2 of this series.

However well tree structures work for view state, a complex application may require all the underlying model state to be composed of retained, mutable, arbitrarily referenced objects.

Already that is tough to achieve in the Rust type system. But we also want our state objects to retain whatever useful metadata is needed by automated state management tools. To me that screams out for a brand-new domain specific language. But assuming you want a solution that is at least Rust-adjacent, this is an example of what I think is possible:

#[state]
struct Counter {
    /// The value of this counter
    mut count: Int,
    /// The display color of this counter
    mut color: Color,
}

impl Counter {
    /// Create a zeroed counter with random color
    pub fn new() -> Counter::Owned {
        Counter::Builder {
            count: 0,
            color: new_random_color(),
        }.build()
    }
}

#[state]
struct App {
    /// All the counters in the application
    mut list: List<Counter>,
    /// The computed sum of all counter values
    #[compute]
    total: Int,
}

impl App<'_> {
    /// Automatically called whenever necessary to update the total
    fn compute_total(&self) -> i32 {
        self.counters
            .iter()
            .map(|counter| counter.count.get())
            .sum()
    }
}

Once the model state is declared, it should plug into any GUI framework painlessly. For example, here is what it could look like under the Dioxus-esque psudo-Rust used previously in this series:

#[widget]
fn counter_ui(counter: Counter::Ref, list: List<Counter>::Ref) -> Element {
    let Counter { count, color, .. } = use_transaction(&counter);

    view! {
        button { text: "X", onclick: use_transaction(list).remove(counter.id()) },
        input { property: count, style: format!("border: solid {color}") },
        button { text: "+", onclick: count.set(count.get() + 1) },
        button { text: "-", onclick: count.set(count.get() - 1) },
    }
}

#[widget]
fn app_ui() -> Element {
    let state = use_state(App::default);
    let State { list, total, .. } = use_transaction(&state);

    view! {
        for counter in list.iter() {
            counter_ui {
                counter: counter.detach(),
                list: list.get().detach(),
            }
        },
        button { text: "+", onclick: counters.push(Counter::new()) },
        input { property: total, editable: false },
    }
}

But by moving the model into a dedicated state management framework, we’ve broken free of the widget tree. But there are other benefits besides. Such a framework could broadcast your state to attached debugging tools. It could have built-in FFI support. And it could automatically manage an undo/redo stack!

Obviously such voodoo magic as shown above is not trivial to achieve. There are a few nice implementations for JavaScript, but it may be a long time before any state management framework for Rust reaches that level, is bug free, performant, and ergonomic. But I have some nontrivial demos up and running! If you want to help out with my proof of concept ahead of the coming blog posts, give me a ping via your Matrix client of choice: samsartor:matrix.org

In part 3 will discuss the types of problems you encounter when everything is shared and mutable, and the various ways people have solved those problems.