Foreword
Welcome to the Vizia book!
Vizia is a crate for building GUI applications. It presently supports Windows, Linux, and MacOS desktop targets, as well as audio plugins and Web.
The Vizia project's home is https://github.com/vizia and the main Vizia repository is https://github.com/vizia/vizia.
This book aims to act as a guide and reference for Vizia, and hopes to teach you how to use Vizia, whether you've done any GUI programming in the past or not.
Getting Started
Installing Rust
The Vizia framework is built using the Rust programming language. Currently, to use Vizia, you will need to install the Rust compiler by following the instructions over at https://www.rust-lang.org/learn/get-started.
Running the Examples
The Vizia repository on github contains a number of example applications. To run these examples, first clone the repository to a local directory, then with your terminal of choice, navigate to this directory and run the following command:
cargo run --example name_of_example
Where name_of_example
should be replaced with the example name.
Join the Community
For help with vizia, or to get involved with contributing to the project, come join us on our Discord server.
Overview
In this quick start guide we'll build a very simple counter application consisting of two buttons, one for incrementing the counter and one for decrementing, and a label showing the counter value.
This guide will introduce the reader to the basics of vizia, including setting up an application, composing and modifying views, layout, styling, reactivity, localization and accessibility. The final application will look like the following:
Reactive UI
Vizia is a reactive UI framework. This means that visual elements which represent some state of the application will update when this state changes. Interacting with these visual elements causes the application state to change.
A reactive UI then is a feedback loop of application state change and visual element updates.
In Vizia, this pattern can be broken down into four concepts:
- Models - Data representing the state of an application.
- Views - The visual elements which present the application state as a graphical user interface.
- Binding - The link between model data and views which causes them to update when the data changes.
- Events - Messages which views send to models to trigger changes to the data.
Setting Up
Creating a new project
First, let's create a new Rust project using the following command:
cargo new --bin hello_vizia
This will generate a hello_vizia
directory with the following contents:
.
├── Cargo.toml
├── .git
├── .gitignore
└── src
└── main.rs
Adding Vizia as a dependency
Open the Cargo.toml
file and add the following to the dependencies:
[package]
name = "hello_vizia"
version = "0.1.0"
edition = "2021"
[dependencies]
vizia = {git = "https://github.com/vizia/vizia"}
Creating an Application
The first step to building a GUI with vizia is to create an application. Creating a new application creates a root window and a context
. Views declared within the closure passed to Application::new()
are added to the context and rendered into the root window.
Add the following code to the main.rs
file, replacing the hello world code that was generated for us:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
// Content goes here
})
.run();
}
The run()
method on the Application
causes the program to enter the event loop and for the main window to display.
We can run our application with cargo run
in the terminal, which should result in the following:
Modifying the Window
When creating an Application
the properties of the window can be changed using window modifiers. These modifiers are methods called on the application prior to calling run()
.
For example, the title()
and inner_size()
window modifiers can be used to set the title and size of the window respectively.
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
})
.title("Counter")
.inner_size((400, 150))
.run();
}
Adding Views
Views are the building bocks of a vizia GUI and are used to visually present model data and to act as controls which, when interacted with, send events to mutate model data.
We'll learn more about models and events in the following sections.
Adding a label
We can declare a Label
view with the following code:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
Label::new(cx, "Hello Vizia");
})
.title("Counter")
.inner_size((400, 150))
.run();
}
The first argument to the new()
method of the label is a mutable reference to Context
, shortened to cx
. This allows the view to build itself into the application. For the second argument we pass it a string to display.
Modifying Views
Modifiers are used to customize the appearance and behavior of views in a declarative way. Many of the built-in modifiers in Vizia can be applied to any View, which includes built-in views as well as user-defined views.
Customizing the label with a modifier
Applying modifiers to a view changes the properties of a view without rebuilding it. For example, we can use the background_color()
modifier to set the background color of the label view:
Label::new(cx, "Hello Vizia")
.background_color(Color::rgb(200, 200, 200));
Note how this overrides the default background color of the label, which is provided by a CSS stylesheet.
Multiple modifiers can be chained together to achieve more complex view configuration. Because modifiers are just changing the properties of a view, the order of modifiers is not important.
Label::new(cx, "Hello Vizia")
.width(Pixels(200.0))
.border_width(Pixels(1.0))
.border_color(Color::black())
.background_color(Color::rgb(200, 200, 200));
View specific modifiers
Some views have modifiers which are specific to that view type. For example, the Slider
view has a modifier for setting the slider range
:
Slider::new(cx, AppData::value)
.range(0.0..100.0);
View specific modifiers can still be combined with regular modifiers, and the order doesn't matter. Both of these produce the same result:
Slider::new(cx, AppData::value)
.range(0.0..100.0)
.width(Pixels(200.0));
Slider::new(cx, AppData::value)
.width(Pixels(200.0))
.range(0.0..100.0);
Composing Views
Composition of views is achieved through container views. These views take a closure which allows us to build child views within them. Some container views may also arrange their content in a particular way.
For example, the HStack
container view will arrange its contents into a horizontal row. Let's use this to declare the rest of the views for our counter application:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"));
Button::new(cx, |cx| Label::new(cx, "Increment"));
Label::new(cx, "0");
});
})
.title("Counter")
.inner_size((400, 150))
.run();
}
For now we have hard-coded the label to display the number 0, but later we will hook this up to some model data so that it updates when the data changes. We've also removed the modifiers from the label, as we'll be replacing these with CSS styling later on.
Note that the Button
view is also a container, and is designed to allow things like a button with both text and a leading or trailing icon.
Composing views together forms a tree, where each view has a single parent and zero or more children. For example, for the code above the view tree can be depicted with the following diagram:
Customizing the Layout
So far we have a horizontal row of buttons and a label, but they're positioned in the top left corner. Let's use layout modifiers to position the views in the center of the window with some space between them.
Centering the views
By default the HStack
view will stretch to fill its parent, in this case the window. We can center the contents of the HStack
by applying stretch space around all of the children, which we can do using the child_space()
modifier and setting it to Stretch(1.0)
. Then we can add horizontal space between the children using the col_between()
modifier:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"));
Button::new(cx, |cx| Label::new(cx, "Increment"));
Label::new(cx, "0");
})
.child_space(Stretch(1.0))
.col_between(Pixels(20.0));
})
.inner_size((400, 100))
.run();
}
Understanding the layout system
The layout system used by vizia is called morphorm and can achieve results similar to flexbox on the web but with fewer concepts to learn. With morphorm there is only space and size. Vizia determines the position and size of views based on a number of layout properties which can be configured. A detailed guide of the layout system can be found here.
Styling the Application
Previously we saw how modifiers can be used to style views inline. However, vizia also allows views to be styled with Cascading Style Sheets (CSS) so that style rules can be shared by multiple views. Additionally, stylesheets can be reloaded at runtime by pressing the F5 key.
Adding class names to the views
First we'll add some class names to our views, using the class
style modifier, so we can target them with a CSS stylesheet:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.class("inc");
Label::new(cx, "0")
.class("count");
})
.class("row");
})
.title("Counter")
.inner_size((400, 150))
.run();
}
Creating a stylesheet
Next, we'll create a style.css
file in the src
directory with the following CSS code:
.row {
child-space: 1s;
col-between: 20px;
}
button {
border-width: 0px;
}
button.dec {
background-color: rgb(170, 50, 50);
}
button.inc {
background-color: rgb(50, 170, 50);
}
label.count {
child-space: 1s;
border-width: 1px;
border-color: #808080;
border-radius: 4px;
width: 50px;
height: 32px;
}
Adding the stylesheet to the app
Finally, we'll add the CSS file to the vizia application using the .add_stylesheet()
function on the context. Here we're using the include_style!()
macro, which will dynamically load the stylesheet at runtime in debug mode, but include the stylesheet into the binary in release mode. This should be done just after creating the application:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
// Add the stylesheet to the app
cx.add_stylesheet(include_style!("src/style.css"))
.expect("Failed to load stylesheet");
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.class("inc");
Label::new(cx, "0")
.class("count");
})
.class("row");
})
.title("Counter")
.inner_size((400, 150))
.run();
}
If we run the app now with cargo run
we get the following:
Animating Styles with Transitions
Many of the style and layout properties in vizia can be animated. The simplest way to animate style properties is through CSS transitions.
Transitions are animations for style rule properties which apply when a view matches that rule. Transitions are specified with the transition
CSS property, and you must specify the property to animate and the duration of the animation. Optionally you can also specify any delay on the animation, as well as the timing function used.
The default styling for some of the built-in views already has some of these transition. For example, if you hover a button you'll see its background color animate to a slightly lighter color.
Declaring a transition
For example, we can create a transition for the background color of a view when hovered:
use vizia::prelude::*;
const STYLE: &str = r#"
.my_view {
background-color: red;
}
.my_view:hover {
background-color: blue;
transition: background-color 100ms;
}
"#;
fn main () {
Application::new(|cx|{
cx.add_stylesheet(STYLE);
Element::new(cx)
.class("my_view")
.size(Pixels(200.0));
})
.run();
}
Note here that we have not used the include_style!()
macro within the call to cx.add_stylesheet
as the stylesheet is defined as a constant within the Rust code.
// GIF here
Note that the transition only occurs when the cursor hovers the element and not when the cursor leaves the element (unless the transition did not complete when the cursor left). This is because the transition has been specified on the :hover
state of the element, and so the background color will transition when going to this state.
To transition back again, we need to specify a transition on the non-hover state as well:
.my_view {
background-color: red;
transition: background-color 100ms;
}
.my_view:hover {
background-color: blue;
transition: background-color 100ms;
}
// GIF here
Model Data
So far we've created the views for our counter application but we haven't declared the application data with the count value we want to display and modify.
Application data in Vizia is stored in models. Views can then bind to the data in these models in order to react to changes in the data.
Declaring a model
A model definition can be any type, typically a struct, which implements the Model
trait:
pub struct AppData {
pub count: i32,
}
impl Model for AppData {}
Building the model into the tree
To use a model, an instance of the data must be built into the application with the build()
method:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
AppData { count: 0 }.build(cx); // Build the data into the app
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.class("inc");
Label::new(cx, "0")
.class("count");
})
.class("row");
})
.title("Counter")
.inner_size((400, 200))
.run();
}
This builds the model data into the tree, in this case at the root Window
. Internally, models and views are stored separately, however, for processes like event propagation, models can be thought of as existing within the tree, with an associated view.
Therefore, the model-view tree for the above code can be depicted with the following diagram:
If the AppData
had been built within the contents of the HStack
, then the model would be associated with the HStack
rather than the Window
.
Lenses and Data Binding
Now that we have some model data we can bind the count
to the Label
view.
Data binding is the concept of linking model data to views, so that when the model data is changed, the views observing this data update in response.
In Vizia, data binding is achieved through the use of lenses. A lens is an object which allows you to select some part of a model and inspect its value. These lens objects are then used to form a binding between views and these parts of the model, updating when only these specific parts have changed.
Generating lenses
The Lens
derive macro can be used to generate a lens for each field of a struct. These lenses can then be used to transform a reference to the struct into a reference to each of its fields. The generated lenses are given the same name as the field and placed in a module with the same name as the struct. For example, deriving Lens
on the model we defined before:
#[derive(Lens)]
pub struct AppData {
count: i32,
}
impl Model for AppData {}
A lens to the count
field of the AppData
struct is generated as AppData::count
.
Binding the label
With the generated AppData::count
lens, we can bind the count
data to the Label
by passing the lens in place of the string:
use vizia::prelude::*;
fn main() {
Application::new(|cx|{
AppData { count: 0 }.build(cx);
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.class("inc");
Label::new(cx, AppData::count) // Bind the label to the count data
.class("count");
})
.class("row");
})
.title("Counter")
.inner_size((400, 100))
.run();
}
This sets up a binding which updates the value of the label whenever the count
changes. We can depict this with the following diagram, where the green arrow shows the direct link between the data and the label:
Modifier bindings
Many modifiers also accept a lens as well as a value. When a lens is supplied to a modifier, a binding is set up which will update the modified property when the bound to model data changes. For example:
#[derive(Lens)]
pub struct AppData {
color: Color,
}
...
Label::new(cx, "Hello World")
.background_color(AppData::color);
Mutating State with Events
The label is now bound to the data so that it updates when the count changes, so now we need to hook up the buttons to change the count.
Vizia uses events to communicate actions to update model or view data. These events propagate through the tree, typically from the view which emits the event, up through the ancestors of the view, to the main window and through any models on the way.
Declaring events
An event contains a message which can be any type, but is typically an enum. We'll declare an event enum with two variants, one for incrementing the count and one for decrementing:
pub enum AppEvent {
Increment,
Decrement,
}
Emitting events
Events are usually emitted in response to some action on a view. For this we can the on_press
callback provided by the Button
. When the button is pressed this callback is called. We can use the provided EventContext
to emit our events up the tree:
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.on_press(|ex| ex.emit(AppEvent::Decrement))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.on_press(|ex| ex.emit(AppEvent::Increment))
.class("inc");
The flow of events from the buttons, up through the visual tree, to AppData
model can be described with the following diagram, where the red arrows indicate the direction of event propagation:
Handling events
Events are handled by views and models with the event()
method of the View
or Model
traits. Let's fill in the Model
implementation by implementing the event
method:
impl Model for AppData {
fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
event.map(|app_event, meta| match app_event {
AppEvent::Decrement => self.count -= 1,
AppEvent::Increment => self.count += 1,
});
}
}
Calling map()
on an event attempts to cast the event message to the specified type and calls the provided closure if it succeeds.
The closure provides the message type and an EventMeta
, which can be used to query the origin and target views of the event, or to consume the event to prevent it propagating further.
If we run the application now we can see that the buttons cause the state to mutate, which then causes the label to update.
Making the Counter Reusable
In this section we're going to turn our counter into a component by declaring a custom view. This will make our counter reusable so we can easily create multiple instances or export the counter as a component in a library.
Step 1: Creating a custom view struct
First we declare a struct which will contain any view-specific state:
pub struct Counter {}
Although we could store the count
value within the view, we've chosen instead to make this view 'stateless', and instead we'll provide it with a lens to bind to some external state (typically from a model), and some callbacks for emitting events when the buttons are pressed.
Step 2: Implementing the view trait
Next, we'll implement the View
trait for the custom counter view:
impl View for Counter {}
The View
trait has methods for responding to events and for custom drawing, but for now we'll leave this implementation empty.
Step 3: Building the sub-components of the view
Next we'll implement a constructor for the counter view. To use our view in a vizia application, the constructor must build the view into the context, which returns a Handle
we can use to apply modifiers on our view.
impl Counter {
pub fn new(cx: &mut Context) -> Handle<Self> {
Self {
}.build(cx, |cx|{
})
}
}
The build()
function, provided by the View
trait, takes a closure which we can use to construct the content of the custom view. Here we move the code which makes up the counter:
impl Counter {
pub fn new(cx: &mut Context) -> Handle<Self> {
Self {
}.build(cx, |cx|{
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.on_press(|ex| ex.emit(AppEvent::Decrement))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.on_press(|ex| ex.emit(AppEvent::Increment))
.class("inc");
Label::new(cx, AppData::count)
.class("count");
})
.child_space(Stretch(1.0))
.col_between(Pixels(20.0));
})
}
}
Step 4: User-configurable binding
The label within the counter is currently using the AppData::count
lens, however, this will only work if that specific lens is in scope. To make this component truly reusable we need to pass a lens in via the constructor. To do this we use a generic and constrain the type to implement the Lens
trait. This trait has a Target
associated type which we can use to specify that the binding is for an i32
value. Then we simply pass the lens directly to the constructor of the label:
impl Counter {
pub fn new<L>(cx: &mut Context, lens: L) -> Handle<Self>
where
L: Lens<Target = i32>,
{
Self {
}.build(cx, |cx|{
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, "Decrement"))
.on_press(|ex| ex.emit(AppEvent::Decrement))
.class("dec");
Button::new(cx, |cx| Label::new(cx, "Increment"))
.on_press(|ex| ex.emit(AppEvent::Increment))
.class("inc");
Label::new(cx, lens)
.class("count");
})
.child_space(Stretch(1.0))
.col_between(Pixels(20.0));
})
}
}
Step 5 - User-configurable events
The last part required to make the counter truly reusable is to remove the dependency on AppEvent
. To do this we'll add a couple of callbacks to the counter to allow the user to emit their own events when the buttons are presses.
Adding callbacks to the view
First, change the Counter
struct to look like this:
pub struct Counter {
on_increment: Option<Box<dyn Fn(&mut EventContext)>>,
on_decrement: Option<Box<dyn Fn(&mut EventContext)>>,
}
These boxed function pointers provide the callbacks that will be called when the increment and decrement buttons are pressed.
Custom modifiers
Next we'll need to add some custom modifiers so the user can configure these callbacks. To do this we can define a trait and implement it on Handle<'_, Counter>
:
pub trait CounterModifiers {
fn on_increment<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self;
fn on_decrement<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self;
}
We can use the modify()
method on Handle
to directly set the callbacks when implementing the modifiers:
impl<'a> CounterModifiers for Handle<'a, Counter> {
fn on_increment<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self {
self.modify(|counter| counter.on_increment = Some(Box::new(callback)))
}
fn on_decrement<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self {
self.modify(|counter| counter.on_decrement = Some(Box::new(callback)))
}
}
Internal event handling
Unfortunately we can't just call these callbacks from the action callback of the buttons. Instead we'll need to emit some internal events which the counter can receive, and then the counter can call the callbacks. Define an internal event enum for the counter like so:
pub enum CounterEvent {
Decrement,
Increment,
}
We can then use this internal event with the buttons:
Button::new(
cx,
|ex| ex.emit(CounterEvent::Decrement),
|cx| Label::new(cx, "Decrement")
)
.class("dec");
Button::new(
cx,
|ex| ex.emit(CounterEvent::Increment),
|cx| Label::new(cx, "Increment")
)
.class("inc");
Finally, we respond to these events in the event()
method of the View
trait for the Counter
, calling the appropriate callback:
impl View for Counter {
fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
event.map(|counter_event, meta| match counter_event{
CounterEvent::Increment => {
if let Some(callback) = &self.on_increment {
(callback)(cx);
}
}
CounterEvent::Decrement => {
if let Some(callback) = &self.on_decrement {
(callback)(cx);
}
}
});
}
}
To recap, now when the user presses on one of the buttons, the button will emit an internal CounterEvent
, which is then handled by the Counter
view to call the appropriate callback, which the user can set using the custom modifiers we added using the CounterModifiers
trait.
Step 6: Using the custom view
Finally, we can use our custom view in the application:
fn main() {
Application::new(|cx|{
cx.add_stylesheet(include_style!("src/style.css"))
.expect("Failed to load stylesheet");
AppData { count: 0 }.build(cx);
Counter::new(cx, AppData::lens)
.on_increment(|cx| cx.emit(AppEvent::Increment))
.on_decrement(|cx| cx.emit(AppEvent::Decrement));
})
.title("Counter")
.inner_size((400, 150))
.run();
}
We pass it the AppData::lens
, but the custom view can accept any lens to an i32
value. We also provide it with callbacks that should trigger when the increment and decrement buttons are pressed. In this case the callbacks will emit AppEvent
events to mutate the model data.
When we run our app now it will seem like nothing has changed. However, now that our counter is a component, we could easily add multiple counters all bound to the same data (or different data):
fn main() {
Application::new(|cx|{
cx.add_stylesheet(include_style!("src/style.css"))
.expect("Failed to load stylesheet");
AppData { count: 0 }.build(cx);
Counter::new(cx, AppData::count)
.on_increment(|cx| cx.emit(AppEvent::Increment))
.on_decrement(|cx| cx.emit(AppEvent::Decrement));
Counter::new(cx, AppData::count)
.on_increment(|cx| cx.emit(AppEvent::Increment))
.on_decrement(|cx| cx.emit(AppEvent::Decrement));
Counter::new(cx, AppData::count)
.on_increment(|cx| cx.emit(AppEvent::Increment))
.on_decrement(|cx| cx.emit(AppEvent::Decrement));
})
.title("Counter")
.inner_size((400, 150))
.run();
}
Localizing the Application
An important part of building a GUI is making sure the application is usable for different regions around the world. Vizia uses fluent to provide translatable text for an application.
Creating fluent files
Fluent files provide a key-value store for translated text strings which vizia uses to localize text in an application.
Let's add two fluent (.ftl) files to our application. We'll call them the same name, counter.ftl
, but place them within separate directories, en-Us
and es
, within a resources
directory.
Your project folder structure should now look like this:
.
├── Cargo.toml
├── .git
├── .gitignore
└── src
├── resources
│ ├── en-US
│ │ └── counter.ftl
│ └── es
│ └── counter.ftl
├── main.rs
└── style.css
resources/en-US/counter.ftl
inc = Increment
dec = Decrement
resources/es/counter.ftl
inc = Incrementar
dec = Decrementar
Adding translations to the application
cx.add_translation(
langid!("en-US"),
include_str!("resources/en-US/counter.ftl").to_owned(),
);
cx.add_translation(
langid!("es"),
include_str!("resources/es/counter.ftl").to_owned(),
);
Localizing text
To localize the text in our application we use the Localized
type within the labels of the buttons, passing the translation keys to the constructor:
Button::new(cx, |cx| Label::new(cx, Localized::new("dec")));
Button::new(cx, |cx| Label::new(cx, Localized::new("inc")));
When the application is run these Localized
objects are replaced with the translated strings from the fluent files based on the system locale.
Testing localization
The locale used for selecting translations is stored in a model called the Environment
. By default the locale used for translations is set to the system locale, however, we can use an EnvironmentEvent
to set the locale to a user-specified value. This is useful for testing the localization of an application.
cx.emit(EnvironmentEvent::SetLocale(langid!("es")));
If we run our app now we'll see that the text has been translated into Spanish. Because the buttons are set up to hug their content, the widths of the buttons have automatically updated to accommodate the slightly longer text strings.
Note that if you're following this tutorial on a machine where the system locale is already set to Spanish then you'll see the Spanish translations without needing to emit the SetLocale
event. To see the English versions of the text replace the "es"
with "en-US"
when emitting the event.
Making the Application Accessible
Making the application accessibility is about making it so that assistive technologies, such as a screen reader, can navigate and query the application.
Our application so far is actually already mostly accessible as the built-in views, such as the buttons, are already set up to be accessible. However, even though the built-in views are accessible, this does not mean the app is automatically accessible.
For the case of our counter, when the increment or decrement buttons are pressed, causing the count to change, a screen reader does not know to speak the current count to inform the user of the change. To account for this we can use something called a 'live region'.
A live region is a view which has changing content but is not itself interactive. In the counter application a label shows the current counter value. This label is not itself interactive but has content which changes, and so should be marked as a live region. This will cause, for example, a screen reader to announce the value when the count changes.
A view can be marked as a live region with the live()
modifier:
Label::new(cx, AppData::count)
.class("count")
.live(Live::Assertive);
If we were to use our counter application with a screen reader enabled now, the count value would be spoken when either of the buttons are pressed.
The Final Code
Rust
use vizia::prelude::*;
// Define the application data
#[derive(Lens)]
pub struct AppData {
count: i32,
}
// Define events for mutating the application data
pub enum AppEvent {
Increment,
Decrement,
}
// Mutate application data in response to events
impl Model for AppData {
fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
event.map(|app_event, meta| match app_event {
AppEvent::Decrement => self.count -= 1,
AppEvent::Increment => self.count += 1,
});
}
}
// Define a custom view for the counter
pub struct Counter {
on_increment: Option<Box<dyn Fn(&mut EventContext)>>,
on_decrement: Option<Box<dyn Fn(&mut EventContext)>>,
}
impl Counter {
pub fn new<L>(cx: &mut Context, lens: L) -> Handle<Self>
where
L: Lens<Target = i32>
{
Self {
on_decrement: None,
on_increment: None,
}.build(cx, |cx|{
HStack::new(cx, |cx|{
Button::new(cx, |cx| Label::new(cx, Localized::new("dec")))
.on_press(|ex| ex.emit(CounterEvent::Decrement))
.class("dec");
Button::new(cx, |cx| Label::new(cx, Localized::new("inc")))
.on_press(|ex| ex.emit(CounterEvent::Increment))
.class("inc");
Label::new(cx, AppData::count)
.class("count")
.live(Live::Assertive);
})
.class("row");
})
}
}
// Internal events
pub enum CounterEvent {
Decrement,
Increment,
}
// Handle internal events
impl View for Counter {
fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
event.map(|counter_event, meta| match counter_event{
CounterEvent::Increment => {
if let Some(callback) = &self.on_increment {
(callback)(cx);
}
}
CounterEvent::Decrement => {
if let Some(callback) = &self.on_decrement {
(callback)(cx);
}
}
});
}
}
// Custom modifiers
pub trait CounterModifiers {
fn on_increment<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self;
fn on_decrement<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self;
}
// Implement custom modifiers
impl<'a> CounterModifiers for Handle<'a, Counter> {
fn on_increment<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self {
self.modify(|counter| counter.on_increment = Some(Box::new(callback)))
}
fn on_decrement<F: Fn(&mut EventContext) + 'static>(self, callback: F) -> Self {
self.modify(|counter| counter.on_decrement = Some(Box::new(callback)))
}
}
fn main() {
Application::new(|cx|{
cx.add_stylesheet(include_style!("src/style.css")).expect("Failed to load stylesheet");
cx.add_translation(
langid!("en-US"),
include_str!("resources/en-US/counter.ftl").to_owned(),
);
cx.add_translation(
langid!("es"),
include_str!("resources/es/counter.ftl").to_owned(),
);
// Uncomment to test with spanish locale.
// If system locale is already Spanish, replace "es" with "en-US".
// cx.emit(EnvironmentEvent::SetLocale(langid!("es")));
// Build model data into the application
AppData { count: 0 }.build(cx);
// Add the custom counter view and bind to the model data
Counter::new(cx, AppData::count)
.on_increment(|cx| cx.emit(AppEvent::Increment))
.on_decrement(|cx| cx.emit(AppEvent::Decrement));
})
.title("Counter")
.inner_size((400, 150))
.run();
}
CSS
.row {
child-space: 1s;
col-between: 20px;
}
button {
border-width: 0px;
}
button.dec {
background-color: rgb(170, 50, 50);
}
button.inc {
background-color: rgb(50, 170, 50);
}
label.count {
child-space: 1s;
border-width: 1px;
border-color: #808080;
border-radius: 4px;
width: 50px;
height: 32px;
}
Fluent
resources/en-US/counter.ftl
inc = Increment
dec = Decrement
resources/es/counter.ftl
inc = Incrementar
dec = Decrementar
Data Binding
Data binding is the concept of linking model data to views, so that when the model data is changed, the views observing this data update automatically in response. Therefore, it is data binding which provides the mechanism for reactivity in Vizia.
In Vizia, data binding is achieved through the use of lenses. A lens is an object which allows you to select some part of a model and inspect its value. These lens objects are then used to form a binding between views and these parts of the model, updating when only these specific parts have changed.
Lenses
The Lens
derive macro can be used to generate a lens for each field of a struct. These lenses can then be used to transform a reference to the struct into a reference to each of its fields. The generated lenses are given the same name as the field and placed in a module with the same name as the struct. For example, given the following definition of some model data:
#[derive(Lens)]
pub struct AppData {
color: Color,
}
impl Model for AppData {}
A lens to the color
field of the AppData
struct is generated as AppData::color
.
Property Binding
We can then use this lens with the background_color
modifier of a view to set up a binding, so that when the data changes the background color is updated. Passing lenses to modifiers is known as property binding.
#[derive(Lens)]
pub struct AppData {
color: Color,
}
impl Model for AppData {}
fn main() {
Application::new(|cx|{
Label::new(cx, "Hello Vizia").background_color(AppData::color);
}).run();
}
View Binding
Some views accept a lens as an input. When provided a lens, the view sets up a binding to the data. For example, the Label
view accepts a lens to any type which implements ToString
:
#[derive(Lens)]
pub struct Person {
pub name: String,
}
impl Model for Person {}
Application::new(|cx|{
Label::new(cx, Person::name);
})
.run()
When the name
field changes, the text of the label updates to show the new value.
The Data Trait
When views bind to model data the binding system must determine whether the data has changed. To do this it stores a copy of the data for each binding. To be able to compare the previous data to the new version, the data type must implement the Data
trait.
Note that this is only required for the data types which are bound to. The model itself does not need to implement Data
unless a view is to bind to the entire model and not just a field within.
Most simple types already implement the Data
trait, but for custom types there is a derive macro that can be used as long as the field types also implement the Data
trait:
#[derive(Clone, Data)]
pub struct CustomData {
text: String,
}
The Data
trait also requires that the type implements Clone
. This is because when the binding system has determined the data has changed, it must replace its stored copy of the data with a new one so it can recompare on the next update cycle.
Lens Map
The map()
method on a lens can be used to derive data from the target of the lens. This is useful for when the lens target is not the right type for the binding, but a value of the correct type can be derived from it.
For example, let's say we have some string data representing a name in our model, but we only want to display the first letter within a label:
use vizia::prelude::*;
#[derive(Lens)]
pub struct AppData {
pub name: String,
}
impl Model for AppData {}
fn main() {
Application::new(|cx|{
AppData {
name: String::from("John Doe"),
}.build(cx);
Label::new(cx, AppData::name.map(|name| name.chars().nth(0).unwrap()));
})
.inner_size((400, 100))
.run();
}
Note that in this example we're assuming that the string is not empty.
Now when the name field of the model changes the label will update to display the new first letter.
Conditional Views
The Binding
view provides a way to explicitely control which parts of the view tree get rebuilt when some state changes, specifically anything within the content closure of a Binding
view.
Because of this, a regular if
statement can be used to conditionally rebuild views. In the following example, a label view is built into the tree when a boolean state is true, else the view is removed from the tree.
use vizia::prelude::*;
#[derive(Lens)]
struct AppData {
show_view: bool,
}
enum AppEvent {
ToggleShowView,
}
impl Model for AppData {
fn event(&mut self, cx: &mut Context, event: &mut Event) {
event.map(|app_event, _| match app_event {
AppEvent::ToggleShowView => self.show_view ^= true,
});
}
}
fn main() {
Application::new(|cx|{
AppData {
show_view: false,
}.build(cx);
Label::new(cx, "Show View")
.on_press(|cx| cx.emit(AppEvent::ToggleShowView));
Binding::new(cx, AppData::show_view, |cx, show|{
if show.get(cx) {
Label::new(cx, "Surprise!");
}
});
})
.inner_size((400, 100))
.run();
}
Binding Nested Data
Let's say we have the following application data structure:
#[derive(Lens)]
pub struct AppData {
nested: NestedData,
}
#[derive(Lens)]
pub struct NestedData {
name: String,
}
Provided that both the parent and nested structures derive the Lens
trait, we can use the then()
lens modifier to produce a lens which targets the nested data:
fn main() {
Application::new(|cx|{
AppData {
nested: NestedData {
name: String::from("John Doe"),
},
}.build(cx);
Label::new(cx, AppData::nested.then(NestedData::name));
})
.inner_size((400, 100))
.run();
}
If the nested data structure does not dderive Lens
, then the map_ref()
modifier can be used:
fn main() {
Application::new(|cx|{
AppData {
nested: NestedData {
name: String::from("John Doe"),
},
}.build(cx);
Label::new(cx, AppData::nested.map_ref(|nested| &nested.name));
})
.inner_size((400, 100))
.run();
}