Tracker

A tracker in this context just means a data type that's able to track changes to itself. For example, if we increment the counter of the model we used for our first app, the model could tell us later that the counter changed during the last update function.

Relm4 does not promote any implementation of a tracker. You're free to use any implementation you like, you can even implement a tracker yourself. In this example however, we'll use the tracker crate that provides a simple macro that implements a tracker for us automatically.

Using this technique, we will implement a small program which displays two randomly picked icons that are controlled by two buttons:

App screenshot

When pressing a button, the icon above it will change. The background of the application will become green when the two icons are identical:

App screenshot with with equal icons

The tracker crate

The tracker::track macro implements the following methods for your struct fields:

  • get_#field_name()
    Get an immutable reference to your field.

  • get_mut_#field_name()
    Get a mutable reference to your field. Assumes the field will be modified and marks it as changed.

  • set_#field_name(value)
    Get a mutable reference to your field. Marks the field as changed only if the new value isn't equal with the previous value.

  • update_#field_name(fn)
    Update your mutable field with a function or a closure. Assumes the field will be modified and marks it as changed.

To check for changes you can call var_name.changed(StructName::field_name()) and it will return a bool indication whether the field was updated.

To reset all previous changes, you can call var_name.reset().

Example

First we have to add the tracker library to Cargo.toml:

tracker = "0.1"

Now let's have a look at a small example.

#[tracker::track]
struct Test {
    x: u8,
    y: u64,
}

fn main() {
    let mut t = Test {
        x: 0,
        y: 0,
        // the macro generates a new variable called
        // "tracker" that stores the changes
        tracker: 0,
    };

    t.set_x(42);
    // let's check whether the change was detected
    assert!(t.changed(Test::x()));

    // reset t so we don't track old changes
    t.reset();

    t.set_x(42);
    // same value, so no change
    assert!(!t.changed(Test::x()));
}

More information about the tracker crate can be found here.

So in short, the tracker::track macro provides different getters and setters that will mark struct fields as changed. You also get a method that checks for changes and a method to reset the changes.

Using trackers in Relm4 apps

Let's build a simple app that shows two random icons and allows the user to set each of them to a new random icon. As a bonus, we want to show a fancy background color if both icons are the same.

The app we will write in this chapter is also available here. Run cargo run --example tracker from the example directory if you want to see the code in action.

The icons

Before we can select random icons, we need to quickly implement a function that will return us random image names that are available in the default GTK icon theme.

const ICON_LIST: &[&str] = &[
    "bookmark-new-symbolic",
    "edit-copy-symbolic",
    "edit-cut-symbolic",
    "edit-find-symbolic",
    "starred-symbolic",
    "system-run-symbolic",
    "emoji-objects-symbolic",
    "emoji-nature-symbolic",
    "display-brightness-symbolic",
];

fn random_icon_name() -> &'static str {
    ICON_LIST
        .iter()
        .choose(&mut rand::thread_rng())
        .expect("Could not choose a random icon")
}

The model

For our model we only need to store the two icon names and whether both of them are identical.

#[tracker::track]
struct AppModel {
    first_icon: &'static str,
    second_icon: &'static str,
    identical: bool,
}

The message type is also pretty simple: we just want to update one of the icons.

enum AppMsg {
    UpdateFirst,
    UpdateSecond,
}

There are a few notable things for the AppUpdate implementation. First, we call self.reset() at the top of the update function body. This ensures that the tracker will be reset so we don't track old changes.

Also, we use setters instead of assignments because we want to track these changes. Yet, you could still use the assignment operator if you want to apply changes without notifying the tracker.

impl AppUpdate for AppModel {
    fn update(&mut self, msg: AppMsg, _components: &(), _sender: Sender<AppMsg>) -> bool {
        // reset tracker value of the model
        self.reset();

        match msg {
            AppMsg::UpdateFirst => {
                self.set_first_icon(random_icon_name());
            }
            AppMsg::UpdateSecond => {
                self.set_second_icon(random_icon_name());
            }
        }
        self.set_identical(self.first_icon == self.second_icon);

        true
    }
}

The widgets

Now we reached the interesting part of the code where we can actually make use of the tracker. Let's have a look at the complete widget macro:

#[relm4_macros::widget]
impl Widgets<AppModel, ()> for AppWidgets {
    view! {
        main_window = gtk::ApplicationWindow {
            set_class_active: track!(model.changed(AppModel::identical()),
                "identical", model.identical),
            set_child = Some(&gtk::Box) {
                set_orientation: gtk::Orientation::Horizontal,
                set_spacing: 10,
                set_margin_all: 10,
                append = &gtk::Box {
                    set_orientation: gtk::Orientation::Vertical,
                    set_spacing: 10,
                    append = &gtk::Image {
                        set_pixel_size: 50,
                        set_icon_name: track!(model.changed(AppModel::first_icon()),
                            Some(model.first_icon)),
                    },
                    append = &gtk::Button {
                        set_label: "New random image",
                        connect_clicked(sender) => move |_| {
                            send!(sender, AppMsg::UpdateFirst);
                        }
                    }
                },
                append = &gtk::Box {
                    set_orientation: gtk::Orientation::Vertical,
                    set_spacing: 10,
                    append = &gtk::Image {
                        set_pixel_size: 50,
                        set_icon_name: track!(model.changed(AppModel::second_icon()),
                            Some(model.second_icon)),
                    },
                    append = &gtk::Button {
                        set_label: "New random image",
                        connect_clicked(sender) => move |_| {
                            send!(sender, AppMsg::UpdateSecond);
                        }
                    }
                },
            }
        }
    }

    fn post_init() {
        relm4::set_global_css(b".identical { background: #00ad5c; }");
    }
}

The overall UI is pretty simple: A window that contains a box. This box has two boxes itself for showing the two icons and the two buttons to update those icons.

There's also something new. With the pre_init() and post_init() functions you can add custom code that will be run either before or after the code the widget macro generates for initialization. In our case, we want to add custom CSS that sets the background color for elements with class name "identical".

    fn post_init() {
        relm4::set_global_css(b".identical { background: #00ad5c; }");
    }

The track! macro

The track! macro is a simple macro that can be used inside the widget macro and allows us to pass a condition for updates and then the arguments. So the syntax looks like this:

track!(bool_expression, argument, [further arguments])

Let's have a look at its first appearance:

            set_class_active: track!(model.changed(AppModel::identical()),
                "identical", model.identical),

The set_class_active method is used to either activate or disable a CSS class. It takes two parameters, the first is the class itself and the second is a boolean which specifies if the class should be added (true) or removed (false).

The first parameter of the track! macro will be used as a condition to check whether something has changed. If this condition is true, the set_class_active method will be called with all the parameters of the track! macro that follow the condition.

The macro expansion for the track! macro in the generated view function looks roughly like this:

if model.changed(AppModel::identical()) {
    self.main_window.set_class_active("identical", model.identical);
}

That's all. It's pretty simple, actually. We just use a condition that allows us to update our widgets only when needed.

The second track! macro looks very similar but only passes one argument:

                        set_icon_name: track!(model.changed(AppModel::first_icon()),
                            Some(model.first_icon)),

Since the track! macro parses expressions, you can use the following syntax to debug your trackers:

track!(bool_expression, { println!("Update widget"); argument })

The main function

There's one last thing to point out. When initializing our model, we need to initialize the tracker field as well. The initial value doesn't really matter because we call reset() in the update function anyway, but usually 0 is used.

fn main() {
    let model = AppModel {
        first_icon: random_icon_name(),
        second_icon: random_icon_name(),
        identical: false,
        tracker: 0,
    };
    let relm = RelmApp::new(model);
    relm.run();
}

The complete code

Let's review our code in one piece one more time to see how all these parts work together:

use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt, OrientableExt};
use rand::prelude::IteratorRandom;
use relm4::{send, AppUpdate, Model, RelmApp, Sender, WidgetPlus, Widgets};

const ICON_LIST: &[&str] = &[
    "bookmark-new-symbolic",
    "edit-copy-symbolic",
    "edit-cut-symbolic",
    "edit-find-symbolic",
    "starred-symbolic",
    "system-run-symbolic",
    "emoji-objects-symbolic",
    "emoji-nature-symbolic",
    "display-brightness-symbolic",
];

fn random_icon_name() -> &'static str {
    ICON_LIST
        .iter()
        .choose(&mut rand::thread_rng())
        .expect("Could not choose a random icon")
}

enum AppMsg {
    UpdateFirst,
    UpdateSecond,
}

// The track proc macro allows to easily track changes to different
// fields of the model
#[tracker::track]
struct AppModel {
    first_icon: &'static str,
    second_icon: &'static str,
    identical: bool,
}

impl Model for AppModel {
    type Msg = AppMsg;
    type Widgets = AppWidgets;
    type Components = ();
}

impl AppUpdate for AppModel {
    fn update(&mut self, msg: AppMsg, _components: &(), _sender: Sender<AppMsg>) -> bool {
        // reset tracker value of the model
        self.reset();

        match msg {
            AppMsg::UpdateFirst => {
                self.set_first_icon(random_icon_name());
            }
            AppMsg::UpdateSecond => {
                self.set_second_icon(random_icon_name());
            }
        }
        self.set_identical(self.first_icon == self.second_icon);

        true
    }
}

#[relm4_macros::widget]
impl Widgets<AppModel, ()> for AppWidgets {
    view! {
        main_window = gtk::ApplicationWindow {
            set_class_active: track!(model.changed(AppModel::identical()),
                "identical", model.identical),
            set_child = Some(&gtk::Box) {
                set_orientation: gtk::Orientation::Horizontal,
                set_spacing: 10,
                set_margin_all: 10,
                append = &gtk::Box {
                    set_orientation: gtk::Orientation::Vertical,
                    set_spacing: 10,
                    append = &gtk::Image {
                        set_pixel_size: 50,
                        set_icon_name: track!(model.changed(AppModel::first_icon()),
                            Some(model.first_icon)),
                    },
                    append = &gtk::Button {
                        set_label: "New random image",
                        connect_clicked(sender) => move |_| {
                            send!(sender, AppMsg::UpdateFirst);
                        }
                    }
                },
                append = &gtk::Box {
                    set_orientation: gtk::Orientation::Vertical,
                    set_spacing: 10,
                    append = &gtk::Image {
                        set_pixel_size: 50,
                        set_icon_name: track!(model.changed(AppModel::second_icon()),
                            Some(model.second_icon)),
                    },
                    append = &gtk::Button {
                        set_label: "New random image",
                        connect_clicked(sender) => move |_| {
                            send!(sender, AppMsg::UpdateSecond);
                        }
                    }
                },
            }
        }
    }

    fn post_init() {
        relm4::set_global_css(b".identical { background: #00ad5c; }");
    }
}

fn main() {
    let model = AppModel {
        first_icon: random_icon_name(),
        second_icon: random_icon_name(),
        identical: false,
        tracker: 0,
    };
    let relm = RelmApp::new(model);
    relm.run();
}