In this tutorial we are going to build a simple application where the user canclick on a button to increment a counter. It shows how to create a data model,create a UI for said data model and react to user input.
The data model
As in the previous tutorial, we're going to start designing what the data modelshould look like. In our case it's going to be really simple:
- use azul::{prelude::*, widgets::{label::Label, button::Button}};
- struct CounterApplication {
- counter: usize,
- }
Once your app grows beyond the hello-world case, you will possibly also storeUI-related information in here, i.e. how many pixels wide a certain window paneis open and so on.
Layout
The layout part is split in two parts: The CSS and the DOM tree. For rapiddevelopment, azul has a XML-based mode which lets you iterate quickly on theUI layout of new features and compile XML to Rust, so you don't have to sufferthe problem of long compile times which can hinder rapid UI development.However, right now we will focus on doing it in pure Rust instead of mixedRust / XML.
- impl Layout for CounterApplication {
- fn layout(&self, _info: LayoutInfo<Self>) -> Dom<Self> {
- let label = Label::new(format!("{}", self.counter)).dom();
- let button = Button::with_label("Update counter").dom()
- .with_callback(On::MouseUp, update_counter);
- Dom::div()
- .with_child(label)
- .with_child(button)
- }
- }
Notice the .dom()
function on the Button
and the Label
. Thisfunction name is just a convention - most standard widgets have a.dom()
function, which converts the Button
into a Dom
. Thereis no special trait for this because some functions need to takeadditional parameters into account, for example the Svg
widgetneeds access to an SvgCache
and access to the current window, sinceit needs to manage and draw OpenGL and cache SVG polygon shapes.However, not every widget needs the same type of parameters, so the.dom()
function is simply a conventionally named function thattransforms the given widget into a DOM.
Any DOM node can be styled via CSS and the standard widgets simplyhave special CSS class names attached to them - if you wanted to,you could overwrite the layout of standard widgets, but for now,we are going to leave everything at the default.
The goal here is to compose widgets / layouts by appending DOMobjects to each other. Abstractions such as the Button
andLabel
can then be built on top of that, so instead of writing:
- Dom::new(NodeType::Div)
- .with_class("__azul-native-label")
- .with_child(Dom::label("Hello".into()))
… we can write:
- Label::new("Hello").dom()
… which is shorter and more descriptive. Through this mechanism youcan build your UI by composition instead of inheritance, composing widgetsinto your own widget types, so that you can build really high-level abstractionsand let the rest get handled by the widget itself.
This also allows you to dynamically show / hide widgets, based on the currentapplication state:
- if self.should_show_component {
- Label::new("Component is shown!").dom()
- } else {
- Label::new("Component is hidden!").dom()
- }
Azul automatically hashes and diffs the Dom
- while there isa small performance hit for re-creating the Dom
every frame, Rust isfast enough that you'll likely never notice it.
Composing these widgets into a Dom
can be done in several ways:
add_child(child_dom)
- appends the new DOM as a child to the current DOMx_list.iter().map(|x| Dom).collect()
- very good for lists / maps, appending each DOM node as a sibling to the previous oneFor example:
- (0..5).map(|number| Label::new(format!("{}", number + 1)).dom()).collect()
… will build a list with the numbers 1, 2, 3, 4, 5
in seperate labels.Later on you can then layout this list horizontally or vertically orhowever you like in CSS. Or you could do:
- (0..5).map(|number| {
- Dom::div()
- .with_class(if number % 2 == 0 { "even" } else { "odd" })
- }).collect()
… this would allow you to style the component from CSS based on even /odd-ness, for example to get alternating colors (note: Azul supports :even
,:odd
and :nth-child()
pseudo-selectors, you don't need to do it foreven- / odd-ness, this was just an example of how flexible the immediate-modestyling is).
A thing to note is that the UI is thread-safe. While the layout()
function is active, no other thread has access to the data model. It is goodpractice to not cheat the borrow checker by using Rc
or RefCell
to updateyour data model inside the .layout()
function.
This should be enough information about the DOM, later on we'll get intoperformance optimization, things to consider and best practices when workingwith the Dom
. For now, let's see how we can make our UI actually do something.
Handling callbacks
To recap, here's what our app looks like right now:
- use azul::{prelude::*, widgets::{label::Label, button::Button}};
- struct CounterApplication {
- counter: usize,
- }
- impl Layout for CounterApplication {
- fn layout(&self, _info: LayoutInfo<Self>) -> Dom<Self> {
- let label = Label::new(format!("{}", self.counter)).dom();
- let button = Button::with_label("Update counter").dom()
- .with_callback(On::MouseUp, update_counter);
- Dom::div()
- .with_child(label)
- .with_child(button)
- }
- }
You might have already noticed the .with_callback()
method with the mysteriousupdate_counter
argument. update_counter
is the name of a function that wehaven't written yet. Callbacks are internally function pointers, but they are notthe same as callbacks in other languages or frameworks, which is a defining differenceof azul.
In many other frameworks, you do something like this (pseudocode):
- def main():
- my_gui = Gui()
- button = Button()
- button.set_onclick(print_hello)
- my_gui.add_button(button)
- my_gui.run()
- def print_hello():
- print("Hello World")
… while this makes for very short Hello-World examples this modelhas one big problem: Where is the application data being stored?How can the callback change the data of other widgets or communicatebetween widgets (i.e. a text input updating a text label)? Many frameworksleave this up as an "excercise to the reader", which leads to very crudesolutions involving static mutable data, global variables andcomplex inheritance hierarchies or meta-compilers that modify your sourcecode to make passing messages between widgets somewhat bearable.
Azul does a very simple thing: Since it already owns the entire data model,it simply passes a mutable reference to the data model to any callback.This means that any callback can change any component in the app model.While this may sound scary and unmaintainable at first, in practice it worksvery well due to Rusts mutability guarantees. Communication between widgetsworks based on the shared memory of the data model, for example:
If we have a visual widget TextInput
and a widget Label
, they don't knowabout each other. The TextInput
only knows that it should update a String
in the data model, and the Label
only knows that it shoul re-render a certainString
. But there is no label.set_text(text_input->get_current_text())
,because that would require the label to know about the text input field,thereby coupling them together.
A callback function has to have a certain signature (the arguments it takes):It takes a simple CallbackInfo<T>
(which internally contains a mutable referenceto the AppState<T>
, so that you can access your data).
- fn my_callback(event: CallbackInfo<T>) -> UpdateScreen { /**/ }
The first thing you've probably noticed is that we don't have a &mut CounterApplication
,but rather a &mut AppState<CounterApplication>
. Why is that? Early versions of azul hadexactly that, however, the problem with this came with drawing custom shapes and text.For that to work, you usually need access to resources that aren't part of your data model,such as fonts or images. The AppState
stores these resources and the &mut
allows youto dynamically load / unload fonts and images.
Another reason is multithreading - earlier I mentioned that the data model is thread-safe.The AppState
stores an Arc<Mutex<CounterApplication>>
- callbacks can start threads andhand out a clone of that Arc
to a different thread, meaning while a callback is runningand the data model isn't locked, it is possible that a different thread is currently modifyingthe data model - but more on threads later. Some resources can be shared across threadswhile others can't (specifically OpenGL drawing is not thread-safe). There are some thingsthat you can only do on the main thread, and other things that you can offload to other threads.This distinction led to the current design of handing an &mut AppState<T>
to the callbacks,not only a &mut T
.
The callback should return an UpdateScreen
"enum" - UpdateScreen
is currently atypedef for Option<()>
, not an enum. This is because currently Rust doesn't allow customimplementations for ?
operators (with the exception of Option and Result). In practice however,programming callbacks with out the ?
operator is very painful. Usually, if a callbackfails, you don't want the application to panic, you'd just want it to "do nothing".Because UpdateScreen
is just a type definition for Option<()>
, you can use the ?
operatorwithout any problems. In the future, this might be reverted back to an enum if Rust implements custom try operators(at which point the code will switch back to UpdateScreen::Redraw
and UpdateScreen::DontRedraw
again).The constants Redraw
and DontRedraw
(imported from the use prelude::*;
) simply representNone
and Some(())
respectively.
UpdateScreen
serves as a performance optimization - if the returned value of allcallbacks is set to DontRedraw
, then azul doesn't need to redraw the screen at all.Azul will automatically redraw the entire screen on events such as window resizing.Redrawing the screen involves calling the .layout()
function again, so a generalrule of thumb is that your callback should only return Redraw
if it actually changes thevisual contents of the screen. Azul doesn't redraw the screen more than necessary,so you shouldn't need to worry about potential performance concerns - re-layouting andredrawing the screen only takes only 1 - 4 ms (linear to your apps UI complexity).
Right now, a callback needs to be wrapped in a Callback
tuple-struct, because Rusthas a few problems with copying and cloning generic function pointers. Additionally,it enhances readability of the code, so it's likely to be kept in the framework anyways.
But let's look at our callback for the counter application:
- fn update_counter(event: CallbackInfo<CounterApplication>) -> UpdateScreen {
- event.state.data.counter += 1;
- Redraw
- }
A last thing to note: How would you know that the function is only called on aOn::MouseUp
event? Well, you can't know what event called the function -the callback itself has no knowledge about what event called it, it just knowsthat it was called. This is a deliberate design choice - you should not designyour callbacks to react only to certain events, because this makes them harder totest. This means that you can swap out the On::Click
for an On::Hover
in the layoutfunction to make your counter go up when you move the mouse over it for example.
Before azul invokes any callbacks, it updates the current window state, such aswhat keys are currently pressed (in this frame), where the mouse is, how largethe window is, etc. When working on earlier versions of azul, I noticed that whata lot of people need to do is to track the current state of the window and / orcompare it with the previous frame. Doing anything else resulted in the apps datamodel get polluted with unnecessary current_mouse_position
or similar information -things that weren't necessary for the application data, but that I needed to haveaccess to in the callbacks. So they were moved into the framework, so that Azultracks these things for you.
Callbacks are currently not sorted in any particular order. There is also noevent.preventDefault()
to solve the problem of inner-to-outer or outer-to-innercallbacks - the reason for this is that this problem doesn't exist because azul passesthe entire application state to your callback. If you want to stop certain events(which is a rare ocurrence usually), simply store a flag (or enum) in your applicationdata model. Since every callback shares that model, you can run functions only ifthe application is currently in a certain state.
The rest of the application should be pretty self-explanatory: We intialize the counter to 0,open a window and start the application:
- fn main() {
- let mut app = App::new(CounterApplication { counter: 0 }, AppConfig::default()).unwrap();
- let window = app.create_window(WindowCreateOptions::default(), css::native()).unwrap();
- app.run(window).unwrap();
- }
This should result in a window with a label and a button that look similar to the picturebelow (exact style can differ depending on the operating system). If you now click the button,the number should increase by 1 on each click.
Summary
This chapter should have introduced you to the DOM, composing widgets via chaining functions,callbacks and the event model and testing and maintainability of your app.