#vulkan #vulkan-graphics #react #flutter #graphics #declarative-ui

narui

A react-inspired UI library for building multimedia desktop apps with rust and vulkan

2 releases

0.1.1 Dec 13, 2021
0.1.0 Nov 19, 2021

#299 in GUI

26 downloads per month

MIT/Apache

700KB
6K SLoC

narui

A react-inspired UI library for building multimedia desktop apps with rust and vulkan.

  • declarative UI with Ergonomics similar to React with hooks
  • JSX-like syntax for composing widgets
  • clean, readable & familiar looking application code
  • flutter-style box layout algorithm

narui node graph demo gif

Usage

Here is a small introduction of the basic concepts of narui. Many things might sound familiar if you used modern react or flutter.

Make sure to also check out the examples that cover some more advanced topics and contain more complicated code.

Basics

narui UIs are composed of widgets. These building blocks can be anything from a simple box to a complex node graph node or even a whole application. The widgets of an application form a tree, that is partially re-evaluated when needed.

widgets are functions that are annotated with the widget attribute macro and return either Fragment for composed widgets or FragmentInner for primitive widgets.

#[widget]
pub fn square(context: &mut WidgetContext) -> Fragment {
    rsx! {
        <rect fill=Some(color!(#ffffff)) constraint=BoxConstraints::tight(10.0, 10.0)>
    }
}

The widgets that are defined that way can then be used in other widgets or as the application toplevel via the rsx macro:

fn main() {
    render(
        WindowBuilder::new(),
        rsx_toplevel! {
            <square />
        },
    );
}

Composition

narui follows the principle of composition over inheritance: You build small reusable pieces that then form larger widgets and applications. To enable that, narui widgets can have parameters and children.

#[widget(color = color!(#00aaaa))]  // we assign a default value to the color attribute which is used when color is unspecified
pub fn colored_column(children: FragmentChildren, color: Color, context: &mut WidgetContext) -> Fragment {
    rsx! {
        <rect fill=Some(color)>
            <padding padding=EdgeInsets::all(10.0)>
                <column>
                    {children}
                </column>
            </padding>
        </rect>
    }
}

We can then use that widget like this:

rsx! {
    <colored_container>
        <text>{"Hello, world"}</text>
        <square />
    </colored_container>
}

If we programmatically generate multiple widgets (for example to display a list), we have to manually specify a key so that each widget can be uniquely identified:

rsx! {
    <colored_container>
        {
            (0..10).map(|i| {
                rsx! { 
                    <text key=&i> // <-- explicit key is given here
                        {format!("{}", i)}
                    </text> 
                }
            })
        }
    </colored_container>
}

State, Hooks & Input handling

The context, that is passed to every widget, acts like a pointer into the widget tree (and can therefore be cheaply copied), and is used to associate data to a specific widget.

State management in narui is done using hooks. Hooks work similiar to react hooks. The most simple hook is the context.listenable hook, which is used to store state. Widgets can subscribe to Listenables with the context.listen method and get reevaluated when the state that they listen to changed. Similiarily, the value of a listenable can be updated by using the context.shout method.

#[widget(initial_value = 1)]
pub fn counter(initial_value: i32, context: &mut WidgetContext) -> Fragment {
    let count = context.listenable(initial_value);
    let on_click = move |context: &CallbackContext| {
        context.shout(count, context.spy(count) + 1)
    };

    rsx! {
        <button on_click=on_click>
            <text>
                {format!("{}", context.listen(count))}
            </text>
        </button>
    }
}

Animations

Usually widgets change state in reaction to a outside event like a mouse click or a message sent over a mpsc channel. Sometimes however it can be useful to drive a state change by the widget itself (for example to create animations). Listenables should not be updated during the evaluation of a widget but only in reaction to external events. This way, re-render loops can be avoided in a clean and easy way.

To create animations despite the existance of that rule, one can use the context.after_frame hook, which allows widgets to run a closure after each frame is rendered. This allows widgets to change Listenables in each frame and therefore create animations.

Business logic interaction & interfacing the rest of the world

Interaction with non UI-related code should be done similiar to how interaction with UI related code is done:

  • Listenables should signal the state from business logic to the UI.
  • Events should signal input from the UI to the business logic. This can be simple callbacks as you would to in your UI code, but it can also be more complicated with mpscs or comparable techniques.

The first step of interacting with Business logic is to run it. This can be done with the effect hook manually or by using the thread hook as a utility over that. For a simple example of how that can be acomplished, see examples/stopwatch.rs.

Custom rendering

narui allows widgets defined in downstream application code to emit fully custom vulkan api calls including drawcalls. This is especially important for multimedia applications. Example widgets that could be implemented this way are 3D viewports, image / video views and similiar things.

Dependencies

~57MB
~1M SLoC