Best Practices With GTK + Rust

Date written:
Categories:
software
,
rust
Tags:
rust
,
gtk

I've been writing GTK applications in Rust for a few years now. I've also been in a unique position with a career at System76, where my day job relies on writing software exclusively for Linux in Rust, including GTK widgets and applications. I'm now at a point where I'm comfortable sharing what I've learned, and therefore this post will explain some of the best practices, patterns, and crates that I use today in my day to day job, from the perspective of a Rust-based software developer that had no prior experience in GTK before Rust.

Constructing GTK Widgets With cascade!

The GTK API is awkward to work with in Rust out of the box. Widgets often have many properties, and each property has a method for setting that property. However, as these methods do not return the widget, each method needs be invoked directly from the variable name on each following name. Below is an example of constructing a button:

let button = gtk::Button::new_with_label("OK");
button.get_style_context().add_class(&gtk::STYLE_CLASS_SUGGESTED_ACTION);
button.halign(gtk::Align::Start);
button.hexpand(true);
button.margin_start(12);
button.margin_end(12);
button.connect_clicked(move |button| {});

Enter cascade!

Using the cascade macro from the cascade crate, we can construct our widgets with a builder-like pattern, even though each method does not return a value. This is a popular operator found in the Dart programming language, as it's a useful pattern when developing UIs in code, where each widget may have many properties to be defined. The first line initializes the widget to a temporary variable name, and each following line that starts with .. is expanded to tmp.. At the end of the scope, the temporary variable is returned from the expression.

let button = cascade! {
    gtk::Button::new_with_label("OK");
    ..get_style_context().add_class(&gtk::STYLE_CLASS_SUGGESTED_ACTION);
    ..halign(gtk::Align::Start);
    ..hexpand(true);
    ..margin_start(12);
    ..margin_end(12);
    ..connect_clicked(move |button| {});
}

Nested Cascades

Nesting is also possible, which often happens with boxes and their children, and their children's children. This really makes the construction of GTK widgets more natural.

let container = cascade! {
    gtk::Box::new(gtk::Orientation::Vertical, 12);
    ..margin_start(24);
    ..margin_end(24);
    ..hexpand(true);
    ..add(&cascade! {
        gtk::Label::new("Clicking this button does something mysterious");
        ..set_xalign(1.0);
    });
    ..add(&cascade! {
        gtk::ButtonBuilder::new()
            .label("Click Me")
            .build();
        ...add_class(&gtk::STYLE_CLASS_SUGGESTED_ACTION);
    })
};

Conclusion

One of the benefits of using cascade is that they encourage you to keep property assignments close together. Often seen in many GTK C applications is where widgets are randomly assigned properties in random order: widget1, widget2, widget1, widget3, widget2, widget1, etc. As the cascade operator always refers to the variable in the previous statement, property assignments are grouped together.

Personally, I feel like this is a pattern should be supported by the Rust language, as it solves many of the problems that we have today with designing public APIs with the builder pattern. Sometimes it make sense for a function to return &Self, or Self, but the cascade pattern is useful in a wide variety of scenarios that neither solution is optimal for. Imagine if you could set up a Hashmap like so:

let mut map = Hashmap::new()
    ..insert("key1", "value1")
    ..insert("key2", "value2")
    ..insert("key3", "value3");

Real world examples can be seen in every GTK project written for Pop!_OS, which you can find from our GitHub.

Replicating Inheritance With Shrinkwrap

#[derive(Shrinkwrap)]
pub struct Widget {
    #[shrinkwrap(main_field)]
    container: gtk::Container,
    ...
}

The shrinkwraprs crate contains an attribute macro which allows you to specify a field within a struct as the "main field". It implements Deref for your struct to deref into that field, which thereby allows you to seamlessly call the inner field's methods, without needing to write those methods yourself. It essentially allows you to inherit that inner field's methods, and to use your new type as the inner type when passing it by reference. The only downside is that sometimes the compiler does not know what you mean when you write &value, so you will often need to call value.as_ref().

container.add(custom_widget.as_ref());

Dialog Example

Here's a great example of how useful this can be with creating a custom GTK dialog:

#[derive(Shrinkwrap)]
pub struct CustomDialog {
    some_data: T,
    #[shrinkwrap(main_field)]
    dialog: gtk::Dialog
}

impl CustomDialog {
    fn new() -> Self {}
}

...

let dialog = CustomDialog::new();

if gtk::ResponseType::Accept = dialog.run() {

}

dialog.destroy();

The run() method that is being called on our dialog is actually calling the run() method of the inner gtk::Dialog. We can call all of its methods this way.

Rc and RefCell are an Anti-Pattern

Today, I firmly believe that heap-allocated reference counters (Rc) and interior mutability (RefCell) is an anti-pattern that should be avoided as much as possible in GTK application development, if not entirely. This is a pattern that I've seen in many GTK Rust applications and tutorials today, and I have been guilty of using it in some of my past projects, as well. After spending a few years with Rust and GTK, I've found better ways of writing GTK applications which can avoid them, and which actually turns out to be easier to implement, and maintain in the long run. Message-passing, generational arenas, and entity-component systems, to name a few.

Why

Working with Rc<RefCell<T>> is very similar to working with GObject in C. You have a heap-allocated object that uses runtime reference-counting, with interior mutability, that is only safe to use from the thread in which it was created. As with GObject, you can design your applications to be littered with strong and weak references scattered about your application. Your object is only alive on the heap for as long as at least once strong reference is in existence. Weak references must be upgraded into a strong reference in order to use them, which fails with Option::None if no strong reference exists elsewhere. If you use a strong reference when you should have used a weak reference, you create a memory leak, because the object will never be freed.

Essentially, use of this pattern is difficult to set up correctly, while also becoming a maintenance disaster in the long run, and even incurring some performance penalties. The more you share state across your application with reference counters, the higher the likelihood of your references and their data being mismanaged. State management becomes a difficult battle that's prone to error, easily broken, and difficult to extend. It's very easy to devolve into recursive executions of signals containing shared data which is then recursively borrowed, resulting in a runtime panic. It's also quite difficult to keep track of state changes, when it could be mutated anywhere in the application.

Plea

So, I would personally recommend to everyone to avoid teaching this practice to anyone wanting to learn GTK application development in Rust. It's as difficult to teach as it is to use in practice. It makes Rust application development look ugly and unergonomic, which can scare away any potential developers interested in Rust for writing GTK applications. It certainly raises the bar to developing a complex application.

Separate Logic With Message-Passing

The alternative to using reference counters to share state in multiple locations, with optional interior mutability, is to use message-passing via channels. Instead of connecting a complex function with captured state to each widget signal, you may instead connect a simple closure containing a Sender, whose sole purpose is to send events, and any data associated with the event, to a Receiver located elsewhere in the application. The Receiver could then be attached to the main context in the main thread, or a thread pool in the background.

This simplifies our applications by separating application state from the application widgets entirely, essentially making our GTK widgets stateless. The widgets need not concern themselves about where their events are being sent, or how that data is being used. They only need to be responsible as data collectors that fetch their data from the UI, and then passing it along to the application to process it.

This allows the application to manage events in centralized locations, even if in another thread. It eliminates recursive function executions, avoids the need for complex borrowing, allows the application to batch events, and even verify that they're being processed in the correct order.

glib::MainContext::channel()

Luckily, the Rust bindings for GTK has special functionality unique to the Rust bindings that can help you enact this in your GTK applications. Included with the glib crate is a glib::Sender and glib::Receiver, created by glib::MainContext::channel(). The glib::Receiver can be attached to the main context in the main thread of your application, and awoken when it receives an event to be processed.

let (tx_background, rx_background) = std::sync::mpsc::channel();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);

background_event_loop(tx.clone(), rx_background);

...
let tx_ = tx.clone();
button.connect_clicked(move |_| {
    tx_.send(UiEvent::ButtonClicked(text_entry.get_text()));
});


let tx_ = tx.clone();
let entry = entry.clone();
entry.connect_activated(move |entry| {
    tx_.send(UiEvent::ButtonClicked(entry.get_text()));
});

next.connect_activate(move |_| {
    tx.send(UiEvent::SwitchView(View::Next));
});

...

let mut application_state = Vec::new();

rx.attach(None, move |event| {
    match event {
        UiEvent::SwitchView(view) => widgets.switch_view(view),
        UiEvent::ButtonClicked(data) => {
            widgets.processing(&data);
            tx_background.send(data);
        },
        Uievent::Processed(data) => {
            application_state.push(data);
        }
        UiEvent::Submit => {
            submit(&application_state);
            application_state.clear();
        }
        UiEvent::Refresh => {
            application_state.clear();
        }
        UiEvent::Exit => return gtk::Continue(false),
    }

    gtk::Continue(true)
});

Something important to know is that "move closures" in Rust capture state that is moved into it, so the data that you capture in the attached glib::Receiver will be persistent across each event that is executed, making it an excellent place for storing long-lived application state.

Associating Events With Data Via Generational Indices

Sometimes you may want to know what event is associated with what data. Instead of passing around references, you can use generational arenas to store data that is assigned to a generational indice, and then pass around the generational indices (aka entity IDs) with your requests.

A button that's specific to a certain piece of data could own the entity ID, and submit its events with that entity ID, for the application to use that ID to deal with the data associated with it. Personally, I've been using the slotmap crate with great success for exactly this kind of problem.


let mut entities: SlotMap<WidgetEntity, ()> = SlotMap::new();
let mut widgets: SecondaryMap<WidgetEntity, WidgetType> = SecondaryMap::new();

rx.attach(None, move |event| {
    match event {
        UiEvent::FoundItem(data) => {
            let entity = entities.insert(());
            let widget = create_widget(data);

            widget.connect_button(move || {
                tx.send(UiEvent::Triggered(entity));
            });

            widgets.insert(entity, widget);
        }
        UiEvent::Triggered(entity) => {
            let widget = &widgets[entity];
            do_something_with(widget);
        }
        UiEvent::Refresh => {
            entities.clear();
        }
        UiEvent::Exit => return gtk::Continue(false),
    }

    gtk::Continue(true)
});