Basics

This tutorial series is currently a work in progress. GitHub

The purpose of this tutorial is to demonstrate GTK application development in Rust from an event-drive perspective. After gaining a lot of experience, I have come to the conclusion that this is the best way to develop GTK applications, and through this tutorial I will share what I consider to be best practices.

Besides the first chapter, each chapter will contain a useful application that you will develop alongside the tutorial. Through this, you will gain some insight into how these applications are developed, and gain experience with a variety of aspects that GTK and Rust have to offer to an application developer.

You will learn the following:

  • First and foremost, GLib and GTK
  • How to navigate the GTK-rs API documentation
  • Using GLib's global and thread-local async executors
  • Creating and configuring widgets with cascade macro
  • APP IDs and preventing applications from launching multiple instances
  • Adding an event handler in a GTK application
  • Utilizing an entity-component approach to widget management
  • Adhering to XDG standards
  • Embedding resources into a GTK applications
  • Translating applications with Fluent
  • Packaging for Debian platforms
  • GNOME Human Interface Guidelines

About GTK

WARNING: This tutorial assumes familiarity with Rust.

Before we begin, it is important to know a few things about GTK itself. The architecture that GTK is built upon strongly influences the way that we will interact with it. Yet I won't dive too deeply into the details, because we only need cover what's most important for us as a consumer of the API in Rust.

GTK is built around GLib

GTK is a GUI toolkit built strongly around GLib and it's GLib Object System — which we'll simply call GObject. GLib is essentially a high level cross-platform standard library for C. The GObject portion of GLib enables programming with an object-oriented paradigm.

GTK uses GLib both for it's object system and asynchronous runtime. Every widget type is a GObject class, and most widget classes inherit multiple layers of GTK widget classes. Widgets schedule tasks for concurrent execution on the default async executor (gtk::MainContext::default()), and register signals that react to various property and state changes.

Luckily for us, the behaviors of every class implemented in GTK can be conveniently represented as a trait in Rust. Even better, GTK fully supports a feature known as "GObject Introspection", which is a convenient way of automatically generating quality bindings in other programming languages. This allowed GTK to have first class bindings in a short amount of time.

Initialized widgets are objects

As for what that means to us, GObjects are heap-allocated with interior mutability. Behaviorally, they operate very similarly to how you would expect to work with a Rc<RefCell<T>> type in Rust. You'll never be required to have unique ownership or a mutable reference to modify a widget, as a result.

When you clone a GObject, you are creating a new strong reference to the object. When all strong references have been dropped, the destructor for that object is run. However, cyclic references are possible with strong references which can prevent the strong count from ever reaching 0, so there's an option to downgrade them into a weak reference. We'll be designing our software in a way that mitigates the need for this though.

Widgets have inheritance

Being built in an object-oriented fashion, widgets are built by inheriting other widgets. So a gtk::Box is a subclass of gtk::Container, which is also a subclass of gtk::Widget; and therefore we can downgrade a gtk::Box into a gtk::Container, and we can further downgrade that into a gtk::Widget.

There may be times when an API hands you a gtk::Widget, and you'll need to upgrade that widget into a more specific type of widget if you want to access methods from that widget's class.

Or there may also be times when you just want to simplify your code and downgrade a widget into a gtk::Widget because you're passing it onto something that takes any kind of widget as an input.

Widget classes have traits

In the GTK-rs implementation, methods from classes are conveniently stored in traits. The gtk::Widget, gtk::Container, and gtk::Box classes have their methods stored in their respective gtk::WidgetExt, gtk::ContainerExt, and gtk::BoxExt traits. This will allow you to conveniently handle your widgets in a generic fashion. Maybe you have a function that can be perfectly written as fn function<W: WidgetExt>(widget: &W) {},

GTK is not thread-safe

Finally, although GObjects can be thread-safe, GTK widgets are most definitely not. You should not attempt to send GTK widgets across thread boundaries, which thankfully the Rust type system will not permit. GTK widgets must be created and interacted with exclusively on the thread that GTK was initialized on.

There are crates such as fragile that would allow you to wrap your widgets into a Fragile<T> and send them across threads, but this is most certainly an anti-pattern. The way a Fragile<T> works is that it prevents you from accessing the T inside the wrapper unless you are unwrapping it from the same thread that it was created on. If you design your software correctly, you won't have to resort to this kind of arcane magic. Turn back before it is too late.

Getting Started

Dependencies

Before we begin, ensure that you have the necessary development files installed for GTK. On Debian platforms, you will need:

  • libgtk-3-dev for GTK3
  • libgtk-4-dev for GTK4
  • libwebkit2gtk-4.0-dev if embedding a GTK WebKit View
  • libgtksourceview-4-dev if embedding a GTK Source View

On the Rust side of things, you should install:

  • cargo-edit with cargo install cargo-edit, because that'll make adding dependencies to your project easier.
  • rust-analyzer in your IDE so that you'll have instant feedback about warnings and errors as you type

API Documentation

The API documentation generated on docs.rs lacks descriptions of the APIs. If you want the most complete API documentation, you will need to reference the documentation generated on the gtk-rs website here.

To navigate this API, every widget has its own type, but those types only contain methods for constructing the widget. Methods specific to each widget can be found in the Ext trait for that widget, such as ButtonExt. You may reference the widget type to see what behaviors it implements, such as ContainerExt or WidgetExt.

Finally, each widget also has a Builder type, such as ButtonBuilder. In some cases, the builder type will be the only way to achieve a certain desired effect, such creating a dialog with a gtk::HeaderBar. This is because this method will define each property before the widget is initialized.

Cascade Macro

This macro is an alternative to the builder pattern that I find more useful in general, as the builder type only works up to creation of the widget, rather than calling methods on created widget itself — such as adding widgets to a container.


#![allow(unused)]
fn main() {
let container = cascade! {
    gtk::Box::new(gtk::Orientation::Vertical, 0);
    ..add(&widget1);
    ..add(&widget2);
};
}

Essentially, the first statement creates your widget, and following lines that start with . will allow you to invoke a method on that widget, before finally returning the widget itself.

Creating Your Project

Now we're going to start the process of actually building our first GTK application. Create your project, and add the following dependencies that we need to get started:

cargo new first-gtk-app
cd first-gtk-app
cargo add gtk glib async-channel cascade

Initializing GTK

Now we're ready to start with code. Lets start by setting your application's name, and initializing GTK.

#[macro_use]
extern crate cascade;

use gtk::prelude::*;
use std::process;

fn main() {
    glib::set_program_name("First GTK App".into());
    glib::set_application_name("First GTK App");

    if gtk::init().is_err() {
        eprintln!("failed to initialize GTK Application");
        process::exit(1);
    }

    // Thread will block here until the application is quit
    gtk::main();
}

Using GLib as an Async Runtime

Before moving further, we should know that we can leverage the same async runtime that GTK uses for spawning and scheduling its tasks.

Spawning tasks on the default executor

This will schedule our futures to execute on the main thread, alongside all of the futures scheduled by GTK itself.


#![allow(unused)]
fn main() {
use std::future::Future;

/// Spawns a task on the default executor and waits to receive its output
pub fn block_on<F>(future: F) -> F::Output where F: Future {
    glib::MainContext::default().block_on(future)
}

/// Spawns a task in the background on the default executor
pub fn spawn<F>(future: F) where F: Future<Output = ()> + 'static {
    glib::MainContext::default().spawn_local(future);
}
}

Spawning tasks on the current thread's executor

Using this approach, you can spawn futures onto the executor that is registered to the thread you are blocking from. By default, there is no executor initialized for newly-spawned threads, so you'll have to create and assign them to the current thread when glib::MainContext::thread_default() returns None.


#![allow(unused)]
fn main() {
use std::future::Future;

pub fn thread_context() -> glib::MainContext {
    glib::MainContext::thread_default()
        .unwrap_or_else(|| {
            let ctx = glib::MainContext::new();
            ctx.push_thread_default();
            ctx
        })
}

pub fn block_on<F>(future: F) -> F::Output where F: Future {
    thread_context().block_on(future)
}

pub fn spawn<F>(future: F) where F: Future<Output = ()> + 'static {
    thread_context().spawn_local(future);
}
}

Spawning tasks on a background thread

This is useful if you spawn a background thread and want to execute your futures using a GLib executor on that thread.

use std::future::Future;

pub fn thread_context() -> glib::MainContext {
    glib::MainContext::thread_default()
        .unwrap_or_else(|| {
            let ctx = glib::MainContext::new();
            ctx.push_thread_default();
            ctx
        })
}

pub fn block_on<F>(future: F) -> F::Output where F: Future {
    thread_context().block_on(future)
}

enum BackgroundEvent { DoThis }

fn main() {
    let (bg_tx, bg_rx) = async_channel::unbounded();

    std::thread::spawn(|| {
        block_on(async move {
            while let Ok(request) = bg_rx.recv().await {
                match request {
                    BackgroundEvent::DoThis => do_this().await,

                }
            }
        });
    });
}

Spawning tasks on a thread pool

There is also an option of using glib::ThreadPool, which gives you exactly that. It defaults to the number of virtual CPU cores in the system, and by default parks threads that have been idle for more than 15 seconds. The pool does not have a requirement on mutability for spawning blocking tasks, so you can initialize this as a global variable using once_cell::sync::Lazy to use around your application, if you prefer this over a Rust-native thread pool like rayon. Perhaps to leverage system libraries.

use std::time::Duration;
use std::thread::sleep;

fn main() {
    let pool = glib::ThreadPool::new_shared(None)
        .expect("failed to spawn thread pool");

    let _ = pool.push(|| {
        sleep(Duration::from_secs(1));
        println!("First Task");
    });

    let _ = pool.push(|| {
        sleep(Duration::from_secs(2));
        println!("Second Task");
    });

    let _ = pool.push(|| {
        sleep(Duration::from_secs(3));
        println!("Third Task");
    });

    // Wait for tasks to complete
    while pool.get_unprocessed() > 0 {
        sleep(Duration::from_secs(1));
    }
}

Spawning futures on a glib::ThreadPool

However, this thread pool takes closures as inputs, rather than futures. So if you want to use for futures, you can combine it with the thread default executor above.

use async_io::Timer;
use std::time::Duration;
use std::thread::sleep;
use std::future::Future;

fn thread_context() -> glib::MainContext {
    glib::MainContext::thread_default()
        .unwrap_or_else(|| {
            let ctx = glib::MainContext::new();
            ctx.push_thread_default();
            ctx
        })
}

fn block_on<F>(future: F) -> F::Output where F: Future {
    thread_context().block_on(future)
}

fn main() {
    let pool = glib::ThreadPool::new_shared(None)
        .expect("failed to spawn thread pool");

    let _ = pool.push(|| {
        block_on(async {
            Timer::after(Duration::from_secs(1)).await;
            println!("First Task");
        })
    });

    let _ = pool.push(|| {
        block_on(async {
            Timer::after(Duration::from_secs(2)).await;
            println!("Second Task");
        })
    });

    let _ = pool.push(|| {
        block_on(async {
            Timer::after(Duration::from_secs(3)).await;
            println!("Third Task");
        })
    });

    // Wait for tasks to complete
    block_on(async {
        while pool.get_unprocessed() > 0 {
            Timer::after(Duration::from_secs(1)).await;
        }
    })
}

Event-Driven Approach

In the event-driven approach, event handlers will capture and control access to application state. Widgets will have their signals connected to send events through a channel to these event handlers, without access to the global application state themselves. States can be moved and exclusively owned by their respective event handlers; thereby eliminating the need for reference counters, or the need to share your application states with every widget's signal.

Setting it up

To achieve this, we need an async channel that we can get from the async-channel crate:


#![allow(unused)]
fn main() {
let (tx, rx) = async_channel::unbounded();
}

Now we need some event variants that our channel will emit:


#![allow(unused)]
fn main() {
enum Event {
    Clicked
}
}

Then we will attach the receiver to a future which merely loops on our receiver forever:


#![allow(unused)]
fn main() {
let event_handler = async move {
    while let Ok(event) = rx.recv().await {
        match event {
            Event::Clicked => {

            }
        }
    }
};
}

And spawn this event handler on the default executor:


#![allow(unused)]
fn main() {
// GLib has an executor in the background that will
// asynchronously handle our events on this thread
glib::MainContext::default().spawn_local(event_handler);
}

Your source code should now look like so, and you are now ready to continue to setting up a window with a clickable button.

#[macro_use]
extern crate cascade;

use gtk::prelude::*;
use std::process;

enum Event {
    Clicked
}

fn main() {
    glib::set_program_name("First GTK App".into());
    glib::set_application_name("First GTK App");

    // Initialize GTK before proceeding.
    if gtk::init().is_err() {
        eprintln!("failed to initialize GTK Application");
        process::exit(1);
    }

    // Attach `tx` to our widgets, and `rx` to our event handler
    let (tx, rx) = async_channel::unbounded();

    // Processes all application events received from signals
    let event_handler = async move {
        while let Ok(event) = rx.recv().await {
            match event {
                Event::Clicked => {

                }
            }
        }
    };

    // GLib has an executor in the background that will
    // asynchronously handle our events on this thread
    glib::MainContext::default().spawn_local(event_handler);

    // Thread will block here until the application is quit
    gtk::main();
}

Avoid blocking the default executor

Take careful note that because this async task has been spawned on the same executor as all of GTK's own tasks, you must be careful to avoid doing anything that would block your event handler. If your event handler were to block, the default MainContext would block with it, thereby freezing the GUI.

Tasks that require a lot of CPU and/or I/O should be performed in a background thread. Generally, the only code that should be executed on the default context is code that interacts directly with the widgets — fetching information from widgets, creating new widgets, and updating existing ones.

Creating a Window with a Button

Let's start by setting up a convenience function for spawning futures on the default executor. This will be necessary to send messages through the async channel.


#![allow(unused)]
fn main() {
use std::future::Future;

/// Spawns a task on the default executor, without waiting for it to complete
pub fn spawn<F>(future: F) where F: Future<Output = ()> + 'static {
    glib::MainContext::default().spawn_local(future);
}
}

Creating the App struct

I typically have a single App struct where all application state and GTK widgets that are regularly interacted with are stored. We're going to start with a struct that contains a gtk::Button and a u32 "clicked" variable.


#![allow(unused)]
fn main() {
use async_channel::Sender;

struct App {
    pub button: gtk::Button,
    pub clicked: u32,
}

impl App {
    pub fn new(tx: Sender<Event>) -> Self {}
}
}

When creating the application, we will take ownership of the Sender that we created earlier, and pass this into every .connect_signal() method that is called on a widget. The .connect_signal() methods will create a future on the main context that idles until the condition for that future has been emitted. A gtk::Button, for example, has a .connect_clicked() method which will have its callbacks invoked when clicked is emitted — which happens on a click of the button.

Note that you may connect multiple callbacks onto the same signal. If you wish to remove one, you should be careful to store the SignalHandlerId that is returned from the .connect_signal() method. Then call widget.disconnect(id) to remove the signal registered to that widget. If you only wish to temporarily block a signal, you can call widget.block_signal(id) and widget.unblock_signal(id) respectively.

Creating widgets for our app

First, we will create the button that we will have the user click. The button will have a label which reads, "Click Me". The border will be set to 4 so that the button isn't hugging the edges of the container it is attached to. And then will program it to send an event when it is clicked.


#![allow(unused)]
fn main() {
let button = cascade! {
    gtk::Button::with_label("Click Me");
    ..set_border_width(4);
    ..connect_clicked(move |_| {
        let tx = tx.clone();
        spawn(async move {
            let _ = tx.send(Event::Clicked).await;
        });
    });
};
}

Note that since we are using an async channel, the sender has to be awaited when it is sending a value. We can use GLib's default executor to await our send.

If the sender happens to block, it could block the default executor and thereby freeze the application. If you're using an unbounded receiver, it will never block on a send, so you will not have to worry about this.

When using a bounded receiver, you should ensure that the tasks is spawned on the executor so that at least the sender can safely wait for its turn to send without blocking our application. However, there is no reason to use a bounded receiver for receiving events, because you'll simply cause the executor to fill up with unresolved tasks.

Next is creating a container widget to hold our button. This container will also invoke .show_all() to make the container visible, and all of the widgets inside the container.


#![allow(unused)]
fn main() {
let container = cascade! {
    gtk::Box::new(gtk::Orientation::Vertical, 0);
    ..add(&button);
    ..show_all();
};
}

Creating the window

Next we we will create the Toplevel window for this application, and attach our container to the window. We will set a title, connect the event to be called when window is deleted, and also set the default icon for our application. The Toplevel window is the main window of your application. A window can only have one widget attached to it, which we will assign with the .add() method. The .set_title() method will set the title of your application. The .connect_delete_event method is invoked whenever the window is destroyed, and we will program this to call gtk::main_quit() to stop the mainloop, thereby having gtk::main() return, which has our application quit.


#![allow(unused)]
fn main() {
let _window = cascade! {
    gtk::Window::new(gtk::WindowType::Toplevel);
    ..add(&container);
    ..set_title("First GTK App");
    ..set_default_size(300, 400);
    ..connect_delete_event(move |_, _| {
        gtk::main_quit();
        gtk::Inhibit(false)
    });
    ..show_all();
};
}

One last thing that should be done for window managers is to set a default icon for the application:


#![allow(unused)]
fn main() {
gtk::Window::set_default_icon_name("icon-name-here");
}

Now we can finally return our App struct, which should look like so:


#![allow(unused)]
fn main() {
impl App {
    pub fn new(tx: Sender<Event>) -> Self {
        let button = cascade! {
            gtk::Button::with_label("Click Me");
            ..set_border_width(4);
            ..connect_clicked(move |_| {
                let tx = tx.clone();
                spawn(async move {
                    let _ = tx.send(Event::Clicked).await;
                });
            });
        };

        let container = cascade! {
            gtk::Box::new(gtk::Orientation::Vertical, 0);
            ..add(&button);
            ..show_all();
        };

        let _window = cascade! {
            gtk::Window::new(gtk::WindowType::Toplevel);
            ..set_title("First GTK App");
            ..add(&container);
            ..connect_delete_event(move |_, _| {
                gtk::main_quit();
                gtk::Inhibit(false)
            });
            ..show_all();
        };

        gtk::Window::set_default_icon_name("icon-name-here");

        Self { button, clicked: 0 }
    }
}
}

Responding to the clicked event

In the example below, you can see that we have passed ownership of the App into the event handler. The clicked property is incremented whenever we receive Event::Clicked. The button's label is updated to show how many times it has been clicked.

fn main() {
    glib::set_program_name("First GTK App".into());
    glib::set_application_name("First GTK App");

    // Initialize GTK before proceeding.
    if gtk::init().is_err() {
        eprintln!("failed to initialize GTK Application");
        process::exit(1);
    }

    // Attach `tx` to our widgets, and `rx` to our event handler
    let (tx, rx) = async_channel::unbounded();

    let mut app = App::new(tx);

    // Processes all application events received from signals
    let event_handler = async move {
        while let Ok(event) = rx.recv().await {
            match event {
                Event::Clicked => {
                    app.clicked += 1;
                    app.button.set_label(&format!("I have been clicked {} times", app.clicked));
                }
            }
        }
    };

    // GLib has an executor in the background that will
    // asynchronously handle our events on this thread
    glib::MainContext::default().spawn_local(event_handler);

    // Thread will block here until the application is quit
    gtk::main();
}

You may run the application with cargo run and try it out.

GTK Widget Reference

List of roughly every widget included in GTK

Containers

Containers control the behavior and layout of the widgets within them.

  • AspectFrame: Ensures that the widget retains the same aspect when resized
  • Box: Lays widgets in vertical or horizontal layouts
  • ButtonBox: Arranges buttons within the container, and themes may style buttons packed this way in a nicer way
  • Expander: Shows/hides a widget with a button that expands to reveal a hidden widget
  • FlowBox: Lays widgets horizontally, and dynamically shifts them to new rows as the parent container shrinks
  • Frame: Displays a frame around a widget
  • Grid: Lays widgets within a grid of rows and columns, with each widget occupying a X,Y position with a defined width and height
  • HeaderBar: Replaces the title bar, where widgets can be packed from the start, the center, or the end of the bar
  • Notebook: Identical to a stack, but has tabs for switching between widgets. Essentially a Stack + StackSwitcher with a set style
  • Paned: Containers two widgets side-by-side with a boundary between them that allows the user to resize between them
  • Revealer: Conceals and reveals a widget with an animation
  • ScrolledWindow: Makes the contained widget scrollable
  • Stack: Stores multiple widgets, but only one widget is shown at a time. May be combined with a StackSwitcher to have tabs
  • Toolbar: Bar at the top of the window for containing tool items

Lists

Containers with selectable widgets

  • ComboBox: Used in conjuction with a tree model to show a list of options to select from
  • ComboBoxText: Streamlined variant of a ComboBox to choose from a list of text options
  • IconView: Think of a file browser with mouse drag selections. Essentially a FlowBox-like container with a grid of icons with text
  • ListBox: Each widget is an interactive row in a list, which may be activated or clicked, and may support multiple selections
  • TreeView: Used to present tabular data, with each row being an object in the list, and each column a field of that object

Text

Containers which display or receive text

  • Label: Displays text without any ability to copy or edit the text
  • Entry: Text box for a single line of text
  • TextView: Multi-line text box
  • SearchEntry: Entry designed for use for searches
  • SearchBar: Toolbar that reveals a search entry when the user starts typing

Buttons

Widgets that can be clicked or activated by keyboard

  • AppChooserButton: Button that shows an app chooser dialog
  • Button: Interactive widget that may contain text, an image, or other widgets
  • CheckButton: Check mark with a label that can be toggled on/off
  • ColorButton: Displays a color and shows a color chooser dialog to select a different one
  • FileChooserButton: Shows a file chooser dialog to select file(s) or folder(s)
  • FontButton: Displays a font and shows a font chooser dialog ot select a different one
  • LinkButton: Hyperlink text button for linking to a URI
  • LockButton: Button with a lock icon for unlocking / locking privileged options
  • MenuButton: Button for showing a popover menu on click
  • RadioButton: When grouped with other radio buttons, only one button may be activate
  • ScaleButton: Button that pops up a scale
  • SpinButton: Number entry with buttons for incrementing and decrementing
  • StackSidebar: Vertical tabs for a stack
  • StackSwitcher: Horizontal tabs for a stack
  • Switch: Toggle button represented as an off/on switch
  • ToggleButton: Button that toggles between being pressed in and unpressed
  • VolumeButton: Button that pops up a volume scale

Display

Widgets that display things

  • DrawingArea: Provides a canvas for drawing images onto
  • EventBox: Makes it possible for a widget to receive button / mouse events
  • GLArea: Context for rendering OpenGL onto
  • Image: Displays a picture
  • InfoBar: Hidden bar that is revealed when info or an error is to be shown
  • LevelBar: Shows a level of a scale
  • ProgressBar: Shows a progress bar
  • Separator: Shows a horizontal or vertical separator
  • ShortcutLabel: Keyboard shortcut label
  • Spinner: Shows a spinning animation
  • Statusbar: Displays information at the bottom of the window

Misc

Everything else

  • PlacesSidebar: Displays frequently visited places in the file system
  • Plug / Socket: Allows sharing widgets across windows

An exhaustive list of the gtk widgets can be found in the widget gallery, but the API version is GTK 4 and above, so crosscheck with the GTK3 docs to get the correct syntax for a widget

ToDo

Full source code for this chapter can be found in the GitHub Repository under examples/02-Todo.

The first application that we will create in this tutorial series is a ToDo list. There are a myriad of ways to create them, but we are going to opt for the event-driven approach with a slotmap. Each task in the ToDo list will be stored in the SlotMap and referenced by its key. On launch of the application, we will load the most-recently modified note. On close, we will write any changes that have yet to be saved before quitting the application. We will also save any changes made every 5 seconds after the last modification.

What is a SlotMap?

SlotMap is described as a "container with persistent unique keys to access stored values." Essentially, it is an arena allocator with generational indices. Imagine a vector where each slot contains a version number. Odd-numbered versions indicate to the allocator that the slot is empty and ready to be filled. On insertion of a new value into a slot, the version is incremented back to an even number, and a key is returned which contains the "generation" version, and the indice of the slot that was used. This gives SlotMap roughly the same performance as accessing an element of an array by its indice, but with an additional version check to determine if key is still valid.

Initialize the project

To get started, create a new project:

cargo new todo
cd todo
cargo add async-channel cascade fomat-macros gio glib gtk slotmap xdg

Then inside the src folder, structure your project like so:

src/
    app.rs
    background.rs
    main.rs
    utils.rs
    widgets.rs

Using gtk::Application

utils.rs

Before we begin, we need to add some utility functions that we'll be using throughout the application. We will be spawning a background thread and executing tasks on it, so we'll need a convenience function for fetching the thread-default context. Likewise, we're going to spawning tasks on the global default context, so we'll need that here as well.


#![allow(unused)]
fn main() {
use std::future::Future;

pub fn thread_context() -> glib::MainContext {
    glib::MainContext::thread_default()
        .unwrap_or_else(|| {
            let ctx = glib::MainContext::new();
            ctx.push_thread_default();
            ctx
        })
}

pub fn spawn<F>(future: F) where F: Future<Output = ()> + 'static {
    glib::MainContext::default().spawn_local(future);
}
}

main.rs

This time we will create a GTK application using the proper gtk::Application setup process. This will take care of initializing GTK for you, and registers your application with an application ID so that you can prevent your application from spawning multiple instances.

#[macro_use]
extern crate cascade;

mod app;
mod background;
mod widgets;
mod utils;

use self::app::App;
use gio::prelude::*;

/// The name that we will register to the system to identify our application
pub const APP_ID: &str = "io.github.mmstick.ToDo";

fn main() {
    let app_name = "Todo";

    glib::set_program_name(Some(app_name));
    glib::set_application_name(app_name);

    // Initializes GTK and registers our application. gtk::Application helps us
    // set up an application with less work
    let app = gtk::Application::new(
        Some(APP_ID),
        Default::default()
    ).expect("failed to init application");

    // After the application has been registered, it will trigger an activate
    // signal, which will give us the okay to construct our application and set
    // up our application logic. We're going to use `app` to create the
    // application window in the future.
    app.connect_activate(|app| {
        let (tx, rx) = async_channel::unbounded();

        let mut app = App::new(app, tx);

        let event_handler = async move {
            while let Ok(event) = rx.recv().await {
                match event {

                }
            }
        };

        utils::spawn(event_handler);
    });

    // This last step performs the same duty as gtk::main()
    app.run(&[]);
}

Calling gtk::Application::new() will run gtk::init() and register your application by the APP_ID that we defined. The general practice for application IDs is to use Reverse domain name notation (RDNN). gtk::Application::connect_activate() signals that GTK is ready for us to construct our application window and set up all of our application logic. This method receives a reference to the gtk::Application itself, which we will later use to create the gtk::ApplicationWindow, which is our top level gtk::Window for our application. gtk::Application::run() will then invoke gtk::main() to set the whole process in motion.

Modeling Our Events

Before going to the next step, we need to think about how we will design our application, and what events our application is going to handle.

A ToDo application will have the following behaviors:

  • Insert a task
  • Remove a task

Each task will be represented in our UI as a row containing the following widgets: A gtk::Entry for writing our task notes; with two gtk::Buttons for inserting a new task below, or removing the task in that row. Our tasks will be stored in a SlotMap, where each task is referenced by their custom key: TaskEntity.

  • Load tasks from a file

When we load our notes from a file, it will be in the form of a String, and each task will be a separate line in that string. The application will automatically create a new task row for each line in that string.

  • Notify when a task is modified
  • Save tasks to a file

Every 5 seconds after the last modification, we will fetch the contents of each gtk::Entry and save them to a file. We will also save the contents of each widget when the application has been closed.

  • Notify that the application has been closed
  • Notify that we are ready to quit

The last two events are an important distinction. When the GTK application has been closed, we will get notified that it has been destroyed. During that time, we will schedule to have our notes saved, and quit the application once they've been saved to the disk.

main.rs


#![allow(unused)]
fn main() {
// Create a key type to identify the keys that we'll use for the Task SlotMap.
slotmap::new_key_type! {
    pub struct TaskEntity;
}

pub enum Event {
    // Insert a task below the given task, identified by its key
    Insert(TaskEntity),

    // A previous task list has been fetched from a file from the background
    // thread, and it is now our job to display it in our UI.
    Load(String),

    // Signals that an entry was modified, and at some point we should save it
    Modified,

    // Removes the task identified by this entity
    Remove(TaskEntity),

    // Signals that we should collect up the text from each task and pass it
    // to a background thread to save it to a file.
    SyncToDisk,

    // Signals that the window has been closed, so we should clean up and quit
    Closed,

    // Signals that the process has saved to disk and it is safe to exit
    Quit,
}
}

Then modify our event handler like so:


#![allow(unused)]
fn main() {
let event_handler = async move {
    while let Ok(event) = rx.recv().await {
        match event {
            Event::Modified => app.modified(),
            Event::Insert(entity) => app.insert(entity),
            Event::Remove(entity) => app.remove(entity),
            Event::SyncToDisk => app.sync_to_disk().await,
            Event::Load(data) => app.load(data),
            Event::Closed => app.closed().await,
            Event::Quit => gtk::main_quit(),
        }
    }
};
}

Events are listed in the order that they are most-likely to be called in, with the most-called events first.

Loading and Saving in the Background

main.rs

To handle events in the background, and within the app itself, we will need two separate channels. One receiver will listen for application events in the main thread which manages the UI. The other receiver will listen for events from the application in a background thread.


#![allow(unused)]
fn main() {
// Channel for UI events in the main thread
let (tx, rx) = async_channel::unbounded();

// Channel for background events to the background thread
let (btx, brx) = async_channel::unbounded();
}

Reading and writing data to a file is a blocking operation that has risk of freezing the application when these operations are occurring on the same thread as the UI. We can therefore avoid hanging the UI simply by passing these tasks off to a background thread.

Spawning the background thread

Next we will spawn a thread, and pass both a clone of our application event sender, and the background event receiver. The glib crate provides a clone macro which can be used


#![allow(unused)]
fn main() {
// Take ownership of a copy of the UI event sender (tx),
// and the background event receiver (brx).
std::thread::spawn(glib::clone!(@strong tx => move || {
    // Fetch the executor registered for this thread
    utils::thread_context()
        // Block this thread on an event loop future
        .block_on(background::run(tx, brx));
}));
}

We're going to attach the background sender to our App in the future, so we need to update our call to App::new() to take both channels as input parameters.


#![allow(unused)]
fn main() {
let mut app = App::new(app, tx, btx);
}

background.rs

Our background event loop is going to start with an async function that looks like this. It all take a sender for events we need to pass back to the UI, and the receiver for receiving events from the UI.


#![allow(unused)]
fn main() {
use crate::Event;
use async_channel::{Receiver, Sender};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::{fs, io};

pub async fn run(tx: Sender<Event>, rx: Receiver<BgEvent>) {

}
}

XDG

On first startup, our application will load the most recently-modified task in memory. Applications should adhere to the XDG standards when they are making decisions about where to store files used by their application. Using the xdg crate, we can get the prefix for your application with the following code:


#![allow(unused)]
fn main() {
let xdg_dirs = xdg::BaseDirectories::with_prefix(crate::APP_ID)
    .unwrap();
}

Because the directory will not exist on a first startup, we need to ensure it's created:


#![allow(unused)]
fn main() {
let data_home = xdg_dirs.get_data_home();

let _ = fs::create_dir_all(&data_home);
}

With the data directory for our app now created, we'll search it for the most recently-created file in this directory, and read that file into memory to pass back to our app:


#![allow(unused)]
fn main() {
if let Some(path) = most_recent_file(&data_home).unwrap() {
    if let Ok(data) = std::fs::read_to_string(&path) {
        let _ = tx.send(Event::Load(data)).await;
    }
}
}

Fetching the most-recent file

The function, most_recent_file() contains the following for reference:


#![allow(unused)]
fn main() {
fn most_recent_file(path: &Path) -> io::Result<Option<PathBuf>> {
    let mut most_recent = SystemTime::UNIX_EPOCH;
    let mut target = None;

    for entry in fs::read_dir(path)?.filter_map(Result::ok) {
        if entry.file_type().map_or(false, |kind| kind.is_file()) {
            if let Ok(modified) = entry.metadata()
                .and_then(|m| m.modified())
            {
                if modified > most_recent {
                    target = Some(entry.path());
                    most_recent = modified;
                }
            }
        }
    }

    Ok(target)
}
}

Handling Events

And then finally, we will start handling the events we receive from the UI. The first being a request to save notes to a file, and the other a request to quit the application.


#![allow(unused)]
fn main() {
/// Events that the background thread's event loop will respond to
pub enum BgEvent {
    // Save tasks to a file
    Save(PathBuf, String),

    // Exit the from the event loop
    Quit
}
}

The Quit event will break from the event loop and then reply to the application that we have finished any task we were waiting on, and it is now safe to exit the application.


#![allow(unused)]
fn main() {
while let Ok(event) = rx.recv().await {
    match event {
        BgEvent::Save(path, data) => {
            let path = xdg_dirs.place_data_file(path).unwrap();
            std::fs::write(&path, data.as_bytes()).unwrap();
        },

        BgEvent::Quit => break
    }
}

let _ = tx.send(Event::Quit).await;
}

Review

At the end, your file should look like this:


#![allow(unused)]
fn main() {
pub async fn run(tx: Sender<Event>, rx: Receiver<BgEvent>) {
    let xdg_dirs = xdg::BaseDirectories::with_prefix(crate::APP_ID).unwrap();

    let data_home = xdg_dirs.get_data_home();

    let _ = fs::create_dir_all(&data_home);

    if let Some(path) = most_recent_file(&data_home).unwrap() {
        if let Ok(data) = std::fs::read_to_string(&path) {
            let _ = tx.send(Event::Load(data)).await;
        }
    }

    while let Ok(event) = rx.recv().await {
        match event {
            BgEvent::Save(path, data) => {
                let path = xdg_dirs.place_data_file(path).unwrap();
                std::fs::write(&path, data.as_bytes()).unwrap();
            },

            BgEvent::Quit => break
        }
    }

    let _ = tx.send(Event::Quit).await;
}
}

Creating the Task Widget Struct

Before we work on the core of the application itself, I typically start by creating the widgets which the application will build upon. Every task in the application will consist of three widgets:

  • gtk::Entry for editing the text of a task
  • gtk::Button for inserting a new task below this task
  • gtk::Button for removing this task

widgets.rs

Take note that because our tasks are created dynamically at runtime, we'll want a way of tracking them, which we can achieve with a SlotMap. The Task struct is going to contain each of the widgets owned by this task, as well as the row where this task was stored.


#![allow(unused)]
fn main() {
use crate::{utils::spawn, Event, TaskEntity};
use async_channel::Sender;
use glib::{clone, SignalHandlerId};
use gtk::prelude::*;

pub struct Task {
    pub entry: gtk::Entry,
    pub insert: gtk::Button,
    pub remove: gtk::Button,

    // Tracks our position in the list
    pub row: i32,
}
}

Now we can construct our widgets.


#![allow(unused)]
fn main() {
impl Task {
    pub fn new(row: i32) -> Self {

    }
}
}

The text entry will horizontally expanded to consume as much space as possible


#![allow(unused)]
fn main() {
let entry = cascade! {
    gtk::Entry::new();
    ..set_hexpand(true);
    ..show();
};
}

Then we'll create our two buttons. It's good practice to use icons over text, because text requires translations.


#![allow(unused)]
fn main() {
let insert = cascade! {
    gtk::Button::from_icon_name(Some("list-add-symbolic"), gtk::IconSize::Button);
    ..show();
};

let remove = cascade! {
    gtk::Button::from_icon_name(Some("list-remove-symbolic"), gtk::IconSize::Button);
    ..show();
};
}

We're not going to program these widgets just yet. Just return them as is to program later:


#![allow(unused)]
fn main() {
impl Task {
    pub fn new(row: i32) -> Self {
        Self {
            insert: cascade! {
                gtk::Button::from_icon_name(Some("list-add-symbolic"), gtk::IconSize::Button);
                ..show();
            },

            remove: cascade! {
                gtk::Button::from_icon_name(Some("list-remove-symbolic"), gtk::IconSize::Button);
                ..show();
            },

            entry: cascade! {
                gtk::Entry::new();
                ..set_hexpand(true);
                ..show();
            },

            row,
        }
    }
}
}

You can find available system icons for your applications using IconLibray.

Creating the App

app.rs

Now we can get to creating our App struct. This will contain all of the values that we will work with throughout the lifetime of our application.


#![allow(unused)]
fn main() {
use crate::{Event, BgEvent, TaskEntity};
use crate::widgets::Task;
use crate::utils::spawn;

use async_channel::Sender;
use glib::clone;
use glib::SourceId;
use gtk::prelude::*;
use slotmap::SlotMap;

pub struct App {
    pub container: gtk::Grid,
    pub tasks: SlotMap<TaskEntity, Task>,
    pub scheduled_write: Option<SourceId>,
    pub tx: Sender<Event>,
    pub btx: Sender<BgEvent>,
}
}

All of our task widgets are going to be stored within the container: gtk::Grid. Each row of this grid will be associated with a task. The first column will contain the gtk::Entry, and the subsequent two columsn are the gtk::Buttons. By using a grid, we can easily keep our widgets perfectly aligned in a grid.

The tasks: SlotMap<TaskEntity, Task> field will contain all the tasks we're currently maintaining. This will be important for looking up which row a task was assigned to.

When an entry has been modified, we're going to spawn a signal that waits until 5 seconds have passed since the last modification before sending an event to the background thread to save the contents of our task list, whose source ID is stored in scheduled_write: Option<SourceId>.

And without requiring much explanation, tx and btx are handles for sending UI and background events.

Setting up the App


#![allow(unused)]
fn main() {
impl App {
    pub fn new(
        app: &gtk::Application,
        tx: Sender<Event>,
        btx: Sender<BgEvent>
    ) -> Self {

    }
}
}

The first step will be creating the gtk::Grid that we are going to assign our widgets to. Each column and row will have 4 units of padding around them, and the widget itself will also have some padding.


#![allow(unused)]
fn main() {
let container = cascade! {
    gtk::Grid::new();
    ..set_column_spacing(4);
    ..set_row_spacing(4);
    ..set_border_width(4);
    ..show();
};
}

Because it will be possible for there to be more tasks than a window can display at one time, this widget will be wrapped within a gtk::ScrolledWindow. By defining that the hscrollbar-policy is Never, this will prevent the scrolling window from horizontally scrolling, but will permit vertical scrolling as necessary.


#![allow(unused)]
fn main() {
let scrolled = gtk::ScrolledWindowBuilder::new()
    .hscrollbar_policy(gtk::PolicyType::Never)
    .build();

scrolled.add(&container);
}

Now we get to setting up our window, which we can create from the &gtk::Application we received. Note that we are connecting our sender along with the scroller to the delete event. When the window is being destroyed, we are going to detach the scroller from the window so that it does not get destroyed alongside it. The purpose of doing so is to keep our gtk::Entry task widgets alive long enough for us to salvage the text in them to save their contents to the disk before we exit the application. Our sender is going to pass a UI event notifying our event handler about the window having been closed.


#![allow(unused)]
fn main() {
let _window = cascade! {
    gtk::ApplicationWindow::new(app);
    ..set_title("Todo");
    ..add(&scrolled);
    ..connect_delete_event(clone!(@strong tx, @strong scrolled => move |win, _| {
        // Detach to preserve widgets after destruction of window
        win.remove(&scrolled);

        let tx = tx.clone();
        spawn(async move {
            let _ = tx.send(Event::Closed).await;
        });
        gtk::Inhibit(false)
    }));
    ..show_all();
};

gtk::Window::set_default_icon_name("icon-name-here");
}

The last step is putting our app together, creating the first row, and returning the App struct:


#![allow(unused)]
fn main() {
let mut app = Self {
    container,
    tasks: SlotMap::with_key(),
    scheduled_write: None,
    tx,
    btx,
};

app.insert_row(0);

app
}

Your file should now look like so:


#![allow(unused)]
fn main() {
use crate::{Event, BgEvent, TaskEntity};
use crate::widgets::Task;
use crate::utils::spawn;

use async_channel::Sender;
use glib::clone;
use glib::SourceId;
use gtk::prelude::*;
use slotmap::SlotMap;

pub struct App {
    pub container: gtk::Grid,
    pub tasks: SlotMap<TaskEntity, Task>,
    pub scheduled_write: Option<SourceId>,
    pub tx: Sender<Event>,
    pub btx: Sender<BgEvent>,
}

impl App {
    pub fn new(app: &gtk::Application, tx: Sender<Event>, btx: Sender<BgEvent>) -> Self {
        let container = cascade! {
            gtk::Grid::new();
            ..set_column_spacing(4);
            ..set_row_spacing(4);
            ..set_border_width(4);
            ..show();
        };

        let scrolled = gtk::ScrolledWindowBuilder::new()
            .hscrollbar_policy(gtk::PolicyType::Never)
            .build();

        scrolled.add(&container);

        let _window = cascade! {
            gtk::ApplicationWindow::new(app);
            ..set_title("Todo");
            ..set_default_size(400, 600);
            ..add(&scrolled);
            ..connect_delete_event(clone!(@strong tx, @strong scrolled => move |win, _| {
                // Detach to preserve widgets after destruction of window
                win.remove(&scrolled);

                let tx = tx.clone();
                spawn(async move {
                    let _ = tx.send(Event::Closed).await;
                });
                gtk::Inhibit(false)
            }));
            ..show_all();
        };

        gtk::Window::set_default_icon_name("icon-name-here");

        let mut app = Self {
            container,
            tasks: SlotMap::with_key(),
            scheduled_write: None,
            tx,
            btx,
        };

        app.insert_row(0);

        app
    }
}
}

Inserting and Removing Tasks

app.rs

Inserting a Row

Back to our App struct, we're going to work on the ability to insert a row by the row indice.


#![allow(unused)]
fn main() {
fn insert_row(&mut self, row: i32) -> TaskEntity {

}
}

When inserting a row, we will want to increment the row value of each task is below the row being added. We can achieve that by iterating our SlotMap of tasks by value, mutably. task that has a row that is greater or equal to the row being inserted will be incremented by 1.


#![allow(unused)]
fn main() {
// Increment the row value of each Task is below the new row
for task in self.tasks.values_mut() {
    if task.row >= row {
        task.row += 1;
    }
}
}

Then we instruct our gtk::Grid to insert this new row, pushing down all rows beneath it:


#![allow(unused)]
fn main() {
self.container.insert_row(row);
}

Next we'll create our task widgets, and assign them to the grid. The .attach() method takes the widget to assign, followed by the column, row, width, and height parameters.


#![allow(unused)]
fn main() {
let task = Task::new(row);

self.container.attach(&task.entry, 0, row, 1, 1);
self.container.attach(&task.insert, 1, row, 1, 1);
self.container.attach(&task.remove, 2, row, 1, 1);
}

We should also ensure that the newly-added gtk::Entry will grab the focus of our keyboard


#![allow(unused)]
fn main() {
task.entry.grab_focus();
}

Now we can assign this newly-created Task to our SlotMap. This will return a key, which we will use as identifiers to the signals we're now going to connect.


#![allow(unused)]
fn main() {
let entity = self.tasks.insert(task);
self.tasks[entity].connect(self.tx.clone(), entity);
return entity;
}

Your method should now look like this:


#![allow(unused)]
fn main() {
fn insert_row(&mut self, row: i32) -> TaskEntity {
    // Increment the row value of each Task is below the new row
    for task in self.tasks.values_mut() {
        if task.row >= row {
            task.row += 1;
        }
    }

    self.container.insert_row(row);
    let task = Task::new(row);

    self.container.attach(&task.entry, 0, row, 1, 1);
    self.container.attach(&task.insert, 1, row, 1, 1);
    self.container.attach(&task.remove, 2, row, 1, 1);

    task.entry.grab_focus();

    let entity = self.tasks.insert(task);
    self.tasks[entity].connect(self.tx.clone(), entity);
    return entity;
}
}

widgets.rs

It is at this point where we are going to start connecting the signals to our task widgets. Add the following method to your Task struct:


#![allow(unused)]
fn main() {
pub fn connect(&mut self, tx: Sender<Event>, entity: TaskEntity) {

}

}

First we will have the entry send Event::Modified whenever it has changed:


#![allow(unused)]
fn main() {
self.entry.connect_changed(clone!(@strong tx => move |_| {
    let tx = tx.clone();
    spawn(async move {
        let _ = tx.send(Event::Modified).await;
    });
}));
}

Then we will program insert button to send Event::Insert(entity) when it has been clicked. Although we will only send this signal if the entry for this task is empty. Note that we are taking the entry widget by weak reference. This will prevent a potential cyclic reference when two widgets happen to depend on each other in their signals. The clone! macro will automatically handle creating the weak reference, and upgrading that reference in our signal.


#![allow(unused)]
fn main() {
self.insert
    .connect_clicked(clone!(@strong tx, @weak self.entry as entry => move |_| {
        if entry.get_text_length() == 0 {
            return;
        }

        let tx = tx.clone();
        spawn(async move {
            let _ = tx.send(Event::Insert(entity)).await;
        });
    }));
}

Then the remove button:


#![allow(unused)]
fn main() {
self.remove.connect_clicked(clone!(@strong tx => move |_| {
    let tx = tx.clone();
    spawn(async move {
        let _ = tx.send(Event::Remove(entity)).await;
    });
}));
}

And to respond to when the user presses the Enter key, which should be treated as equivalent to clicking the insert button:


#![allow(unused)]
fn main() {
    self.entry
        .connect_activate(clone!(@weak self.entry as entry => move |_| {
            if entry.get_text_length() == 0 {
                return;
            }

            let tx = tx.clone();
            spawn(async move {
                let _ = tx.send(Event::Insert(entity)).await;
            });
        }));
}
}

app.rs

Moving back to our app module, we'll add another method for inserting a row. Because our application is going to insert new rows from received events via the TaskEntity that was received in the Insert(TaskEntity) event, we need to add the method that our application is going to call. After fetching the task from the SlotMap, we can use the conveniently-stored row value to determine where we're going to insert a new row.


#![allow(unused)]
fn main() {
pub fn insert(&mut self, entity: TaskEntity) {
    let mut insert_at = 0;

    if let Some(task) = self.tasks.get(entity) {
        insert_at = task.row + 1;
    }

    self.insert_row(insert_at);
}
}

Finally, we get to removing tasks. When we receive that Remove(TaskEntity) event, we're going to call app.remove(entity). We'll ignore any requests to delete the last task from the list, since that would render our application unusable. If we're allowed to remove a task, we'll remove the task from the SlotMap, and call grid.remove_row(&widget) on our container to remove all the widgets from that task's row from the container. The widgets will be automatically destroyed after returning from this function, because the last remaining strong references to them have been wiped out.


#![allow(unused)]
fn main() {
pub fn remove(&mut self, entity: TaskEntity) {
    if self.tasks.len() == 1 {
        return;
    }
    self.remove_(entity);
}

fn remove_(&mut self, entity: TaskEntity) {
    if let Some(removed) = self.tasks.remove(entity) {
        self.container.remove_row(removed.row);

        // Decrement the row value of the tasks that were below the removed row
        for task in self.tasks.values_mut() {
            if task.row > removed.row {
                task.row -= 1;
            }
        }
    }
}
}

And of course, similar to having to increment the row values on insert, we'll do the reverse on removal of a widget.

Signaling when to Save

app.rs

There are two scenarios where we will save our tasks to a file. When the application has been closed, and every 5 seconds after the last modification. To start, lets add a signal that waits 5 seconds before sending the SyncToDisk event:


#![allow(unused)]
fn main() {
pub fn modified(&mut self) {
    if let Some(id) = self.scheduled_write.take() {
        glib::source_remove(id);
    }

    let tx = self.tx.clone();
    self.scheduled_write = Some(glib::timeout_add_local(5000, move || {
        let tx = tx.clone();
        spawn(async move {
            let _ = tx.send(Event::SyncToDisk).await;
        });

        glib::Continue(false)
    }));
}
}

glib::timeout_add_local(5000, ...) will schedule the provided closure to execute on local context after 5 seconds. This function returns an ID which we're storing in the scheduled_write property of our App. If we receive the Modified event again before the 5 seconds have passed, the previous signal will be removed and a new one registered in its place. That'll ensure that it doesn't trigger until after 5 seconds of idle keyboard time has passed.

Next will be programming the SyncToDisk event. We're simply going to collect the text from each non-empty task widget in our slotmap, and combine it into a single string to pass to the background for saving. The fomat formatter from the fomat_macros crate provides a very intuitive means to achieve this.


#![allow(unused)]
fn main() {
pub async fn sync_to_disk(&mut self) {
    self.scheduled_write = None;

    let contents = fomat_macros::fomat!(
        for node in self.tasks.values() {
            if node.entry.get_text_length() != 0 {
                (node.entry.get_text()) "\n"
            }
        }
    );

    let _ = self.btx.send(BgEvent::Save("Task".into(), contents)).await;
}
}

Finally, we can handle that Closed event that was sent when the ApplicationWindow was destroyed:


#![allow(unused)]
fn main() {
pub async fn closed(&mut self) {
    self.sync_to_disk().await;
    let _ = self.btx.send(BgEvent::Quit).await;
}
}

Loading tasks from a file

app.rs

If in the future, we implement the ability to open a different list, we'll need a way of clearing the UI of the previous list. This simply involves popping out every task in the map and removing them one by one.


#![allow(unused)]
fn main() {
pub fn clear(&mut self) {
    while let Some(entity) = self.tasks.keys().next() {
        self.remove_(entity);
    }
}
}

When we receive the contents of a list to load into our UI, we're going to split the string by newlines and create a row for each one, then insert that text into their entries.


#![allow(unused)]
fn main() {
pub fn load(&mut self, data: String) {
    self.clear();

    for (row, line) in data.lines().enumerate() {
        let entity = self.insert_row(row as i32);
        self.tasks[entity].set_text(line);
    }
}
}

widgets.rs

Because we are automatically filling out the contents of the Task::entry for each task that we load from a file, and we are listening to any changes made to these entries when we send the Modified event, we need to block that signal when we are setting the text in the entry. Add the following new property to Task:


#![allow(unused)]
fn main() {
entry_signal: Option<SignalHandlerId>,
}

Which we'll need to assign to None in our Task::new() method. Then change the connect_changed signal for the entry to the following:


#![allow(unused)]
fn main() {
let signal = self.entry.connect_changed(clone!(@strong tx => move |_| {
    let tx = tx.clone();
    spawn(async move {
        let _ = tx.send(Event::Modified).await;
    });
}));

self.entry_signal = Some(signal);
}

Now we can safely add a method for setting the text on this entry, first by blocking that signal, setting the text, and unblocking it:


#![allow(unused)]
fn main() {
pub fn set_text(&mut self, text: &str) {
    let signal = self.entry_signal.as_ref().unwrap();
    self.entry.block_signal(signal);
    self.entry.set_text(text);
    self.entry.unblock_signal(signal);
}
}

Marking & Removing Done Tasks with CheckButtons

We are going to remove the remove buttons from each task and replace them with check buttons. To remove tasks from the list, we will replace the application's title bar with a gtk::HeaderBar, and place a delete button here that will show when any tasks have been checked.

main.rs

We're adding two new events to our Event:


#![allow(unused)]
fn main() {
Delete,
Toggled(bool),
}

Handling it in our event handler:


#![allow(unused)]
fn main() {
Event::Toggled(active) => app.toggled(active),
Event::Delete => app.delete(),
}

widgets.rs

We can track if tasks are completed with check marks, and remove them together in a batch. Add a new field to our Task struct to add a gtk::CheckButton:


#![allow(unused)]
fn main() {
check: gtk::CheckButton
}

Since we're going to use these check marks for removal operations, we can remove the remove button as well.

Then construct the widget and return it:


#![allow(unused)]
fn main() {
Self {
    check: cascade! {
        gtk::CheckButton::new();
        ..show();
    },

    insert: cascade! {
        gtk::Button::from_icon_name(Some("list-add-symbolic"), gtk::IconSize::Button);
        ..show();
    },

    entry: cascade! {
        gtk::Entry::new();
        ..set_hexpand(true);
        ..show();
    },

    entry_signal: None,
    row,
}
}

Then we can add an event for when the button is toggled:


#![allow(unused)]
fn main() {
self.check.connect_toggled(clone!(@strong tx => move |check| {
    let tx = tx.clone();
    let check = check.clone();
    spawn(async move {
        let _ = tx.send(Event::Toggled(check.get_active())).await;
    })
}));
}

app.rs

Task Widgets

Then modify the attachments of these widgets in the app:


#![allow(unused)]
fn main() {
self.container.attach(&task.check, 0, row, 1, 1);
self.container.attach(&task.entry, 1, row, 1, 1);
self.container.attach(&task.insert, 2, row, 1, 1);
}

Delete Button

Now we're going to create the delete button, with both an icon and a label. By default, a button is only permitted to have either an image or a label, but we can force it to show both by setting the always_show_image property. We also don't want this button to be shown when the window is shown, so we need to call .set_no_show_all(true). Since this button performs a destructive action, we should style it as such with .get_style_context().add_class(&gtk::STYLE_CLASS_DESTRUCTIVE_ACTION).


#![allow(unused)]
fn main() {
let delete_button = cascade! {
    gtk::Button::from_icon_name(Some("edit-delete-symbolic"), gtk::IconSize::Button);
    ..set_label("Delete");
    // Show the icon alongside the label
    ..set_always_show_image(true);
    // Don't show this when the window calls `.show_all()`
    ..set_no_show_all(true);
    // Give this a destructive styling to signal that the action is destructive
    ..get_style_context().add_class(&gtk::STYLE_CLASS_DESTRUCTIVE_ACTION);
    // Send the `Delete` event on click
    ..connect_clicked(clone!(@strong tx => move |_| {
        let tx = tx.clone();
        spawn(async move {
            let _ = tx.send(Event::Delete).await;
        });
    }));
};
}

This button widget will be attached to the title bar via the gtk::HeaderBar:


#![allow(unused)]
fn main() {
let headerbar = cascade! {
    gtk::HeaderBar::new();
    ..pack_end(&delete_button);
    ..set_title(Some("ToDo"));
    ..set_show_close_button(true);
};
}

Then modify our ApplicationWindow to change .set_title() for the following:


#![allow(unused)]
fn main() {
..set_titlebar(Some(&headerbar));
}

And update our App struct to add the delete button.


#![allow(unused)]
fn main() {
delete_button: gtk::Button
}

Handling Toggle Events

We will show the delete button only when there is at least one active task checked. We can achieve this by adding another property to the App struct to track how many tasks are actively checked.


#![allow(unused)]
fn main() {
checks_active: u32
}

By default, assigning it in our constructor to 0 of course


#![allow(unused)]
fn main() {
checks_active: 0
}

Then we'll add the toggled method for handling the toggle events. If the event is toggled active, we increment the number. We do the reverse when it is unchecked. If the number is non-zero, we set the button as visible.


#![allow(unused)]
fn main() {
pub fn toggled(&mut self, active: bool) {
    if active {
        self.checks_active += 1;
    } else {
        self.checks_active -= 1;
    }

    self.delete_button.set_visible(self.checks_active != 0);
}
}

Handling Delete Events

When we've been requested to delete tasks that were marked as active, we'll iterate through our tasks and collect the entity IDs of each task that is active. We need to collect these into a vector on the side so that we're not modifying our task list as we're iterating across it. Once we have a list of tasks to remove, we'll call our remove method with each entity ID. Finally, we'll set the checks_active back to 0 and hide the button.


#![allow(unused)]
fn main() {
pub fn delete(&mut self) {
    let remove_list = self.tasks.iter()
        .filter(|(_, task)| task.check.get_active())
        .map(|(id, _)| id)
        .collect::<Vec<TaskEntity>>();

    for id in remove_list {
        self.remove(id);
    }

    self.checks_active = 0;
    self.delete_button.set_visible(false);
}
}

Managing Multiple ToDo Lists