Git Product home page Git Product logo

crochet's Introduction

Crochet, an exploration into reactive UI

This repo contains a prototype for exploration into a possible next-generation reactive UI architecture for Druid. For background, see the blog post Towards a unified theory of reactive UI. It is a fusion of ideas from many sources, including imgui, Jetpack Compose, Moxie, Makepad, Conrod, and others.

The code is not too complex, and I hope people will find reading it (or at least skimming) to be rewarding. Many (but not yet all) of the main types have docstring, so running cargo doc --open is not a bad way to navigate the code. (TODO item: is it reasonably easy to make a Github Action that runs cargo doc, so it doesn't have to be done locally?)

The Crochet architecture

The Crochet architecture centers around a "view tree" (probably also referred to as the "crochet tree" in discussions). A node in this tree is the description of a render object (widget). The central concept of Crochet is this: running your app logic produces a mutation of the view tree. A tree mutation is a delta that describes the new state of the view tree as a sparse set of changes from the old state.

The main interface between the app logic and the toolkit is the Crochet context (usually referred to as "cx"). The app declaratively expresses the new state of the view tree by method calls on the crochet context.

As in Jetpack Compose and Moxie, tree nodes have a stable identity, determined by keys. If a run of the app logic specifies a node with a new key that didn't exist in the old tree (as will always be the case on first run), a node is inserted into the tree. In reverse, if a key is not present in a run of the app logic, the node is deleted. Otherwise, the node is retained with a stable identity; its state is not modified. The attributes and children can be updated in such cases.

By default, the key is the caller of the method producing the node, along with the sequence number of nodes produced with that caller. As in Moxie, Rust's new #[track_caller] feature is used to provide a unique call-site identity. (Jetpack Compose also uses unique identities, but uses a Kotlin compiler plugin to generate them.)

After the app logic runs, producing a tree mutation, it is applied to the render object (widget tree). Basically, this brings the render object tree in sync with the view tree. For newly inserted nodes, a new widget is created, based on the description in the view.

The crochet tree also contains memoization state, using a mechanism similar to Moxie and Jetpack Compose. This will also be familiar to users of React hooks, though the Rules of Hooks are considerably relaxed in Crochet vs React because the track_caller mechanism lets the context keep its place much more accurately than simply relying on the sequence number in its corresponding array.

One of the main ways that Crochet differs from Jetpack Compose is that the context is also used to access actions from events, for example button clicks. Each node in the view tree (conceptually) has an associated action queue. Typically, the app logic retrieves actions from this action queue in the same method call it uses to emit the item. The pattern if button(...) { ... } will be familiar to imgui users.

Skipping

A major performance feature is skipping. Without skipping, the app logic emits the entire view tree on every run, which is convenient but wasteful if most of the tree hasn't changed.

One of the main skipping methods is if_changed, which takes a data argument. If the data has changed (or if the node is being inserted for the first time), then it runs the closure that represents its body. Otherwise, it skips it, effectively copying the corresponding subtree (at basically no cost) from the previous state of the tree. This mechanism is similar to that provided in Jetpack Compose, and is a familiar caching pattern in imgui as well.

One advantage to accessing the action queues through the Crochet context (as opposed to having separate callbacks for actions, as in Jetpack Compose), is that the skipping logic can be sensitive to actions as well. The if_changed method also checks for any nonempty action queues in the subtree, and traverses into the subtree if so.

A hypothesis to be tested in this exploration is that skipping based on explicit (but hopefully low-friction) app logic and the action queues provides efficient dispatching of events, and overall a sparse, incremental performance profile. One reason to be hopeful is that ordinary performance engineering techniques such as profiling and tracing should be effective in guiding the developer where to apply more careful skipping logic, as the control flow remains very simple.

Low level tree mutation

While a declarative approach to creating view nodes is appropriate for random app logic, it is less appealing for structured collections, especially as the scale goes up. For these use cases, I envision opening access to a lower level API, which would require some tracking of the old state of the tree on the application side. Even so, if this logic is encapsulated in a component, it should still be relatively easy to use.

The low level tree mutation API would be expected to support "skip n", "delete n", and versions of update/insert that make that distinction explicitly rather than inferring it from keys. I believe in such cases we don't even need to supply unique id's for items, as the responsibility for tracking widget identity falls entirely on the user of the low level API.

To be useful, this low level API also needs to identify which children have nonempty action queues. That way, for example, a list view would be able to dispatch a button click within one of its items to the app code for that item.

Open questions

There are many. One is whether to support reordering of children within a node. I think the answer is yes, but in the current code, if the tree is A, B and the next run of the app logic produces B, A, then it will delete the A and insert a new instance after the B.

Contributing

This repo is for experimentation and exploration. It uses an optimistic merging policy; feel free to make any changes you feel contribute to the goal of learning something. Commit access will be freely given. The project follows the Rust code of conduct.

The following items are of particular interest:

  • Adapting more widgets to the AnyWidget enum (we're trying to avoid patching Druid, for project coordination reasons).
  • Working out efficient data structures and algorithms for the tree mutation primitives.
  • Experimenting with larger scale collections such as lists and a tree view.
  • Render objects other than widgets, for example tree view items.
  • Deeper exploration of async integration.
  • Scripting from other languages, including refining the Python module.

But overall the goal is to gather evidence for whether this architecture is viable.

License

All files in this repo are released under an Apache 2.0 license. Some code has been cut and pasted from Druid, therefore carries a copyright of "The Druid Authors". See the Druid repo for the definitive authors list.

crochet's People

Contributors

chris-morgan avatar cmyr avatar davelab6 avatar follower avatar jplatte avatar luleyleo avatar maan2003 avatar mtrnord avatar poignardazur avatar raphlinus avatar tbelaire avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

crochet's Issues

List example select lags one frame

When you click on a button in the list example

         self.list_view
            .run(cx, &self.data, |cx, mut is_selected, id: Id, item| {
                Row::new().build(cx, |cx| {
                    if Button::new("Select").build(cx) {
                        new_sel = Some(id);
                        is_selected = true;
                    }
                    let sel_str = if is_selected { "[*]" } else { "[ ]" };
                    Label::new(format!("{} {}", sel_str, item)).build(cx);
                });
            });
        if let Some(id) = new_sel {
            self.list_view.select(id);
        }

This doesn't set is_selected until the next time through the loop, and if you don't move the mouse, it stays with the wrong selection. You have to double click, or click and move the mouse in order to have it update.

This doesn't happen with the create, delete and update buttons, as they run before the buttons loop.

I don't see a nice way to reach back in the loop and change the label, so is there a way to mark the state as invalid and demand a second run through the run function right away?

async example is broken.

I just copy-pasted the async example into a new project and it seems to be broken. It spams

ERROR [crochet::widget::single] Single-child widget was created with 3 children.

on stdout and only displays the label current count: 0.

Hook in responsive localization early

I understand why localization is always treated as an afterthought and every time a new approach to GUI comes up it comes with some version of:

Label::new(format!("current count: {}", self.count)).build(cx);

but I'm here to argue that this is both a bad paradigm and that the reason it doesn't get fixed is that once the GUI toolkit is mature enough to actually tackle the problem, there's too much sunk cost in this model to revisit it.

So I'd like to suggest you actually tackle it early :)

First, translation is not a string that one plasters onto a widget. It's a binding. Bind your widget to a localization unit.

In 20-years-ago command line textual apps, you could get away with such glorified printf, but that model very badly translates onto UI trees.
UI element may be nested, have some internal structure, it's value may have markup and structure (think, <strong>, <sup>, <span> in HTML inside a string), it may have attributes, both textual (button's accesskey or tooltip) but also non-textual (color may be culturally dependent, icon associated with a button may be different for some locales, like rewind back/fwd in RTL cultures or some emojis in Japanese culture) and so on.

So instead of thinking of localization as a printf into a String, we should start talking about a compound object with multiple elements inside it - a Label or a Button or a Menuitem, and a compound localization unit that contains information needed so that those two combined can be laid out and painted on the screen.

Second, in reactive UI, retranslate every time the variable changes, or the locale changes (or the locale resources get updated!) during life cycle of the app.

Those two concepts work together really well - if you annotated by binding, you can just walk your UI Tree and retranslate at will using different localization resources whenever needed. You can cache the result, invalidate that cache and so on, without any overhead on the developer.
You can localize asynchronously (think, you localize into fr-CA but midway through the localization stage you realize that some button cannot be localized and you want to fallback on fr resources, asynchronously load them and have the button be in fr as a fallback). You can do it because the localization pass is not related to how developer annotates the element with localization unit.

How would it look like?

In HTML we do:

l10n.res
key1 = You have { $emailCount } unread emails.
    .accesskey = K
    .tooltip = Number of unread emails

file.html
<label l10n-id="key1" l10n-args="{emailCount: 5}"/>

I think you can do better here. Maybe:

let mut label = Label::new();
label.l10n.args.set("emailCount", 5);
label.l10n.id = "key1";
label.build(cx);

There are deep consequences to that change in three directions:

  1. how you think about UI, ergonomics, developer UX
  2. how you think about DOM, layout, painting, invalidation, reactiveness etc.
  3. how you think about locale management, live language switching and updating etc.

I can dive in all three of them, but I just wanted to start by suggesting shifting the approach to string injections early.

You can also treat an element as either controlled by l10n or manually:

let mut label = Label::new();
// Either
label.content = Content::Manual {
  value: "Static Value with { $emailCount }",
  attributes: {
    "accesskey": "C",
    "tooltip": "Foo"
  },
  args: args!(
    "emailCount": 5
  )
};
// Or
label.content = Content::L10n {
  id: "key1",
  args: l10n_args!(
    "emailCount": 5
  )
};

label.build(cx);

This is actually fairly common in my experience of working with Fluent. An example is when there's a menuitem that either takes a value from some database (say, credit card name) or displays a localized message "Unknown Type".

In such case we want to write:

cc-name-unknown = Unknown Credit Card
    .accesskey = U
    .tooltip  = The type of a credit card could not be recognized
    .icon = @icon-cc-unknown
menuitem.content = if let Some((name, accesskey, description)) = credit_card.get_label_info() {
     Content::Manual {
           value: name,
           attributes: {
               "accesskey": accesskey,
               "tooltip": description,
               "icon": format!("@icon-cc-{}", name),
           }
     }
} else {
    Content::L10n {
          id: "cc-name-unknown",
    }
}; 

This Rust code can be called every time menuitem list has to be rebuilt, but a function that updates translations is independent from it and can run on a different scheduler and react to different events, and be asynchronously loading resources blocking layout/paint, but not blocking this function.

Suggestion: shorthand for common components

For example, button("text", cx) could be shorthand for Button::new("text").build(cx), and this could be a standard pattern for simple components.

The question is: would the simple form be usable in enough places to make it worthwhile? I hope that styling/layout information could be managed out-of-band - perhaps loaded from a file and then mapped onto the widget tree using the stable widget identities, so that you would rarely need to stray from this short form.

Some components might have multiple variations, eg. image_button(..., cx) but if there are more than a couple of common forms it would be better to revert back to the factory style for that component.

Example for IO without futures

It would be nice if there was an example of how to update the UI in reaction to external events without async/await. Or is the idea that one would always use async/await for that? Assuming that arbitrarily blocking in the app logic is a bad idea ๐Ÿ˜„

Avoiding lambdas with capture

Hi Raph! Great work!

At my work we use a custom language which does not support captures in lambdas. Instead we have simple definitions of semantic macros.

Reading the counter.rs source therefore had me wonder... how about an alternative API using begin/end calls instead? I assume the main reason for callbacks is safety: not missing a matching end call.

Matching begin/end using Rust macros might provide increased confusion in traces.
If we just don't enforce this at compile time, control flow could become visibly simpler and more open and powerful, not being coupled to function boundaries. This could also open up for more complex composition.

I think it's a tradeoff worth pondering, despite not necessarily being idiomatic Rust. What do you think?

I'm considering e.g. crashing at runtime if a node ends up unclosed, together with some simple API like cx.push(MyContainer::new()); { ... } cx.pop(); or something similar.

Would love to hear your thoughts on this!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.