Let To-Do App use Libadwaita
Within this chapter we will adapt our To-Do app so that it follows GNOME's HIG.
Let's start by installing Libadwaita and adding the libadwaita
crate to our dependencies as explained in the previous chapter.
The simplest way to take advantage of Libadwaita is by replacing gtk::Application
with adw::Application
.
Filename: listings/todo/5/main.rs
mod task_object;
mod task_row;
mod utils;
mod window;
use gtk::prelude::*;
use gtk::{gio, glib};
use window::Window;
const APP_ID: &str = "org.gtk_rs.Todo5";
fn main() -> glib::ExitCode {
gio::resources_register_include!("todo_5.gresource")
.expect("Failed to register resources.");
// Create a new application
// π changed
let app = adw::Application::builder().application_id(APP_ID).build();
// Connect to signals
app.connect_startup(setup_shortcuts);
app.connect_activate(build_ui);
// Run the application
app.run()
}
// π changed
fn setup_shortcuts(app: &adw::Application) {
app.set_accels_for_action("win.filter('All')", &["<Ctrl>a"]);
app.set_accels_for_action("win.filter('Open')", &["<Ctrl>o"]);
app.set_accels_for_action("win.filter('Done')", &["<Ctrl>d"]);
}
// π changed
fn build_ui(app: &adw::Application) {
// Create a new custom window and present it
let window = Window::new(app);
window.present();
}
Filename: listings/todo/5/window/mod.rs
mod imp;
use std::fs::File;
use gio::Settings;
use glib::{clone, Object};
use gtk::subclass::prelude::*;
use gtk::{
gio, glib, CustomFilter, FilterListModel, NoSelection, SignalListItemFactory,
};
use gtk::{prelude::*, ListItem};
use crate::task_object::{TaskData, TaskObject};
use crate::task_row::TaskRow;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub fn new(app: &adw::Application) -> Self {
// Create new window
Object::builder().property("application", app).build()
}
fn setup_settings(&self) {
let settings = Settings::new(APP_ID);
self.imp()
.settings
.set(settings)
.expect("`settings` should not be set before calling `setup_settings`.");
}
fn settings(&self) -> &Settings {
self.imp()
.settings
.get()
.expect("`settings` should be set in `setup_settings`.")
}
fn tasks(&self) -> gio::ListStore {
self.imp()
.tasks
.borrow()
.clone()
.expect("Could not get current tasks.")
}
fn filter(&self) -> Option<CustomFilter> {
// Get filter state from settings
let filter_state: String = self.settings().get("filter");
// Create custom filters
let filter_open = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow completed tasks
!task_object.is_completed()
});
let filter_done = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow done tasks
task_object.is_completed()
});
// Return the correct filter
match filter_state.as_str() {
"All" => None,
"Open" => Some(filter_open),
"Done" => Some(filter_done),
_ => unreachable!(),
}
}
fn setup_tasks(&self) {
// Create new model
let model = gio::ListStore::new::<TaskObject>();
// Get state and set model
self.imp().tasks.replace(Some(model));
// Wrap model with filter and selection and pass it to the list view
let filter_model = FilterListModel::new(Some(self.tasks()), self.filter());
let selection_model = NoSelection::new(Some(filter_model.clone()));
self.imp().tasks_list.set_model(Some(&selection_model));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
#[weak]
filter_model,
move |_, _| {
filter_model.set_filter(window.filter().as_ref());
}
),
);
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<TaskData> = serde_json::from_reader(file).expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<TaskData>` to `Vec<TaskObject>`
let task_objects: Vec<TaskObject> = backup_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
// Insert restored objects into model
self.tasks().extend_from_slice(&task_objects);
}
}
fn setup_callbacks(&self) {
// Setup callback for activation of the entry
self.imp().entry.connect_activate(clone!(
#[weak(rename_to = window)]
self,
move |_| {
window.new_task();
}
));
// Setup callback for clicking (and the releasing) the icon of the entry
self.imp().entry.connect_icon_release(clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.new_task();
}
));
}
fn new_task(&self) {
// Get content from entry and clear it
let buffer = self.imp().entry.buffer();
let content = buffer.text().to_string();
if content.is_empty() {
return;
}
buffer.set_text("");
// Add new task to model
let task = TaskObject::new(false, content);
self.tasks().append(&task);
}
fn setup_factory(&self) {
// Create a new factory
let factory = SignalListItemFactory::new();
// Create an empty `TaskRow` during setup
factory.connect_setup(move |_, list_item| {
// Create `TaskRow`
let task_row = TaskRow::new();
list_item
.downcast_ref::<ListItem>()
.expect("Needs to be ListItem")
.set_child(Some(&task_row));
});
// Tell factory how to bind `TaskRow` to a `TaskObject`
factory.connect_bind(move |_, list_item| {
// Get `TaskObject` from `ListItem`
let task_object = list_item
.downcast_ref::<ListItem>()
.expect("Needs to be ListItem")
.item()
.and_downcast::<TaskObject>()
.expect("The item has to be an `TaskObject`.");
// Get `TaskRow` from `ListItem`
let task_row = list_item
.downcast_ref::<ListItem>()
.expect("Needs to be ListItem")
.child()
.and_downcast::<TaskRow>()
.expect("The child has to be a `TaskRow`.");
task_row.bind(&task_object);
});
// Tell factory how to unbind `TaskRow` from `TaskObject`
factory.connect_unbind(move |_, list_item| {
// Get `TaskRow` from `ListItem`
let task_row = list_item
.downcast_ref::<ListItem>()
.expect("Needs to be ListItem")
.child()
.and_downcast::<TaskRow>()
.expect("The child has to be a `TaskRow`.");
task_row.unbind();
});
// Set the factory of the list view
self.imp().tasks_list.set_factory(Some(&factory));
}
fn setup_actions(&self) {
// Create action from key "filter" and add to action group "win"
let action_filter = self.settings().create_action("filter");
self.add_action(&action_filter);
}
fn remove_done_tasks(&self) {
let tasks = self.tasks();
let mut position = 0;
while let Some(item) = tasks.item(position) {
// Get `TaskObject` from `glib::Object`
let task_object = item
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
if task_object.is_completed() {
tasks.remove(position);
} else {
position += 1;
}
}
}
}
adw::Application
calls adw::init
internally and makes sure that translations, types, stylesheets, and icons are set up properly for Libadwaita.
It also loads stylesheets automatically from resources as long as they are named correctly.
Looking at our To-Do app we can see that the looks of its widgets changed.
This is because the Default
stylesheet provided by GTK has been replaced with the Adwaita
stylesheet provided by Libadwaita.
Also, our app now switches to the dark style together with the rest of the system.
Boxed lists
Of course Libadwaita is more than just a couple of stylesheets and a StyleManager
.
But before we get to the interesting stuff, we will make our lives easier for the future by replacing all occurrences of gtk::prelude
and gtk::subclass::prelude
with adw::prelude
and adw::subclass::prelude
.
This works because the adw
preludes, in addition to the Libadwaita-specific traits, re-export the corresponding gtk
preludes.
Now we are going let our tasks follow the boxed lists pattern. The HIG does not require us to use this style and there's a good reason for that: it is incompatible with recycling lists. This means they cannot be used with list views and are therefore only appropriate for relatively small lists.
Try to add tasks programmatically and see how many of them you have to add until the UI noticeably slows down. Determine for yourself if you think that is a reasonable number or if we should have rather stuck with list views.
We can use boxed lists by using gtk::ListBox
instead of gtk::ListView
.
We will also add the boxed-list
style class provided by Libadwaita.
Let's implement all these changes in the window.ui
file.
All of the changes are confined within the second child of the ApplicationWindow
.
To see the complete file, just click on the link after "Filename".
Filename: listings/todo/6/resources/window.ui
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="min-content-height">420</property>
<property name="vexpand">True</property>
<property name="child">
<object class="AdwClamp">
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">18</property>
<property name="margin-top">24</property>
<property name="margin-bottom">24</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<child>
<object class="GtkEntry" id="entry">
<property name="placeholder-text" translatable="yes">Enter a Taskβ¦</property>
<property name="secondary-icon-name">list-add-symbolic</property>
</object>
</child>
<child>
<object class="GtkListBox" id="tasks_list">
<property name="visible">False</property>
<property name="selection-mode">none</property>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
In order to follow the boxed list pattern, we switched to gtk::ListBox
, set its property "selection-mode" to "none" and added the boxed-list
style class.
Let's continue with window/imp.rs
.
The member variable tasks_list
now describes a ListBox
rather than a ListView
.
Filename: listings/todo/6/window/imp.rs
use std::cell::RefCell;
use std::fs::File;
use adw::subclass::prelude::*;
use gio::Settings;
use glib::subclass::InitializingObject;
use adw::prelude::*;
use gtk::{gio, glib, CompositeTemplate, Entry, ListBox};
use std::cell::OnceCell;
use crate::task_object::{TaskData, TaskObject};
use crate::utils::data_path;
// Object holding the state
#[derive(CompositeTemplate, Default)]
#[template(resource = "/org/gtk_rs/Todo6/window.ui")]
pub struct Window {
#[template_child]
pub entry: TemplateChild<Entry>,
#[template_child]
pub tasks_list: TemplateChild<ListBox>,
pub tasks: RefCell<Option<gio::ListStore>>,
pub settings: OnceCell<Settings>,
}
// The central trait for subclassing a GObject
#[glib::object_subclass]
impl ObjectSubclass for Window {
// `NAME` needs to match `class` attribute of template
const NAME: &'static str = "TodoWindow";
type Type = super::Window;
type ParentType = gtk::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
// Create action to remove done tasks and add to action group "win"
klass.install_action("win.remove-done-tasks", None, |window, _, _| {
window.remove_done_tasks();
});
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
// Trait shared by all GObjects
impl ObjectImpl for Window {
fn constructed(&self) {
// Call "constructed" on parent
self.parent_constructed();
// Setup
let obj = self.obj();
obj.setup_settings();
obj.setup_tasks();
obj.restore_data();
obj.setup_callbacks();
obj.setup_actions();
}
}
// Trait shared by all widgets
impl WidgetImpl for Window {}
// Trait shared by all windows
impl WindowImpl for Window {
fn close_request(&self) -> glib::Propagation {
// Store task data in vector
let backup_data: Vec<TaskData> = self
.obj()
.tasks()
.iter::<TaskObject>()
.filter_map(Result::ok)
.map(|task_object| task_object.task_data())
.collect();
// Save state to file
let file = File::create(data_path()).expect("Could not create json file.");
serde_json::to_writer(file, &backup_data)
.expect("Could not write data to json file");
// Pass close request on to the parent
self.parent_close_request()
}
}
// Trait shared by all application windows
impl ApplicationWindowImpl for Window {}
We now move on to window/mod.rs
.
ListBox
supports models just fine, but without any widget recycling we don't need factories anymore.
setup_factory
can therefore be safely deleted.
To setup the ListBox
, we call bind_model
in setup_tasks
.
There we specify the model, as well as a closure describing how to transform the given GObject into a widget the list box can display.
Filename: listings/todo/6/window/mod.rs
mod imp;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow};
use gio::Settings;
use glib::{clone, Object};
use gtk::{gio, glib, Align, CheckButton, CustomFilter, FilterListModel, NoSelection};
use crate::task_object::{TaskData, TaskObject};
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub fn new(app: &adw::Application) -> Self {
// Create new window
Object::builder().property("application", app).build()
}
fn setup_settings(&self) {
let settings = Settings::new(APP_ID);
self.imp()
.settings
.set(settings)
.expect("`settings` should not be set before calling `setup_settings`.");
}
fn settings(&self) -> &Settings {
self.imp()
.settings
.get()
.expect("`settings` should be set in `setup_settings`.")
}
fn tasks(&self) -> gio::ListStore {
self.imp()
.tasks
.borrow()
.clone()
.expect("Could not get current tasks.")
}
fn filter(&self) -> Option<CustomFilter> {
// Get filter state from settings
let filter_state: String = self.settings().get("filter");
// Create custom filters
let filter_open = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow completed tasks
!task_object.is_completed()
});
let filter_done = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow done tasks
task_object.is_completed()
});
// Return the correct filter
match filter_state.as_str() {
"All" => None,
"Open" => Some(filter_open),
"Done" => Some(filter_done),
_ => unreachable!(),
}
}
fn setup_tasks(&self) {
// Create new model
let model = gio::ListStore::new::<TaskObject>();
// Get state and set model
self.imp().tasks.replace(Some(model));
// Wrap model with filter and selection and pass it to the list box
let filter_model = FilterListModel::new(Some(self.tasks()), self.filter());
let selection_model = NoSelection::new(Some(filter_model.clone()));
self.imp().tasks_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let task_object = obj
.downcast_ref()
.expect("The object should be of type `TaskObject`.");
let row = window.create_task_row(task_object);
row.upcast()
}
),
);
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
#[weak]
filter_model,
move |_, _| {
filter_model.set_filter(window.filter().as_ref());
}
),
);
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&self.tasks());
self.tasks().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
}
/// Assure that `tasks_list` is only visible
/// if the number of tasks is greater than 0
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<TaskData> = serde_json::from_reader(file).expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<TaskData>` to `Vec<TaskObject>`
let task_objects: Vec<TaskObject> = backup_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
// Insert restored objects into model
self.tasks().extend_from_slice(&task_objects);
}
}
fn create_task_row(&self, task_object: &TaskObject) -> ActionRow {
// Create check button
let check_button = CheckButton::builder()
.valign(Align::Center)
.can_focus(false)
.build();
// Create row
let row = ActionRow::builder()
.activatable_widget(&check_button)
.build();
row.add_prefix(&check_button);
// Bind properties
task_object
.bind_property("completed", &check_button, "active")
.bidirectional()
.sync_create()
.build();
task_object
.bind_property("content", &row, "title")
.sync_create()
.build();
// Return row
row
}
fn setup_callbacks(&self) {
// Setup callback for activation of the entry
self.imp().entry.connect_activate(clone!(
#[weak(rename_to = window)]
self,
move |_| {
window.new_task();
}
));
// Setup callback for clicking (and the releasing) the icon of the entry
self.imp().entry.connect_icon_release(clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.new_task();
}
));
}
fn new_task(&self) {
// Get content from entry and clear it
let buffer = self.imp().entry.buffer();
let content = buffer.text().to_string();
if content.is_empty() {
return;
}
buffer.set_text("");
// Add new task to model
let task = TaskObject::new(false, content);
self.tasks().append(&task);
}
fn setup_actions(&self) {
// Create action from key "filter" and add to action group "win"
let action_filter = self.settings().create_action("filter");
self.add_action(&action_filter);
}
fn remove_done_tasks(&self) {
let tasks = self.tasks();
let mut position = 0;
while let Some(item) = tasks.item(position) {
// Get `TaskObject` from `glib::Object`
let task_object = item
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
if task_object.is_completed() {
tasks.remove(position);
} else {
position += 1;
}
}
}
}
We still have to specify the create_task_row
method.
Here, we create an adw::ActionRow
with a gtk::CheckButton
as activatable widget.
Without recycling, a GObject will always belong to the same widget.
That means we can just bind their properties without having to worry about unbinding them later on.
Filename: listings/todo/6/window/mod.rs
mod imp;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow};
use gio::Settings;
use glib::{clone, Object};
use gtk::{gio, glib, Align, CheckButton, CustomFilter, FilterListModel, NoSelection};
use crate::task_object::{TaskData, TaskObject};
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub fn new(app: &adw::Application) -> Self {
// Create new window
Object::builder().property("application", app).build()
}
fn setup_settings(&self) {
let settings = Settings::new(APP_ID);
self.imp()
.settings
.set(settings)
.expect("`settings` should not be set before calling `setup_settings`.");
}
fn settings(&self) -> &Settings {
self.imp()
.settings
.get()
.expect("`settings` should be set in `setup_settings`.")
}
fn tasks(&self) -> gio::ListStore {
self.imp()
.tasks
.borrow()
.clone()
.expect("Could not get current tasks.")
}
fn filter(&self) -> Option<CustomFilter> {
// Get filter state from settings
let filter_state: String = self.settings().get("filter");
// Create custom filters
let filter_open = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow completed tasks
!task_object.is_completed()
});
let filter_done = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow done tasks
task_object.is_completed()
});
// Return the correct filter
match filter_state.as_str() {
"All" => None,
"Open" => Some(filter_open),
"Done" => Some(filter_done),
_ => unreachable!(),
}
}
fn setup_tasks(&self) {
// Create new model
let model = gio::ListStore::new::<TaskObject>();
// Get state and set model
self.imp().tasks.replace(Some(model));
// Wrap model with filter and selection and pass it to the list box
let filter_model = FilterListModel::new(Some(self.tasks()), self.filter());
let selection_model = NoSelection::new(Some(filter_model.clone()));
self.imp().tasks_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let task_object = obj
.downcast_ref()
.expect("The object should be of type `TaskObject`.");
let row = window.create_task_row(task_object);
row.upcast()
}
),
);
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
#[weak]
filter_model,
move |_, _| {
filter_model.set_filter(window.filter().as_ref());
}
),
);
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&self.tasks());
self.tasks().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
}
/// Assure that `tasks_list` is only visible
/// if the number of tasks is greater than 0
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<TaskData> = serde_json::from_reader(file).expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<TaskData>` to `Vec<TaskObject>`
let task_objects: Vec<TaskObject> = backup_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
// Insert restored objects into model
self.tasks().extend_from_slice(&task_objects);
}
}
fn create_task_row(&self, task_object: &TaskObject) -> ActionRow {
// Create check button
let check_button = CheckButton::builder()
.valign(Align::Center)
.can_focus(false)
.build();
// Create row
let row = ActionRow::builder()
.activatable_widget(&check_button)
.build();
row.add_prefix(&check_button);
// Bind properties
task_object
.bind_property("completed", &check_button, "active")
.bidirectional()
.sync_create()
.build();
task_object
.bind_property("content", &row, "title")
.sync_create()
.build();
// Return row
row
}
fn setup_callbacks(&self) {
// Setup callback for activation of the entry
self.imp().entry.connect_activate(clone!(
#[weak(rename_to = window)]
self,
move |_| {
window.new_task();
}
));
// Setup callback for clicking (and the releasing) the icon of the entry
self.imp().entry.connect_icon_release(clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.new_task();
}
));
}
fn new_task(&self) {
// Get content from entry and clear it
let buffer = self.imp().entry.buffer();
let content = buffer.text().to_string();
if content.is_empty() {
return;
}
buffer.set_text("");
// Add new task to model
let task = TaskObject::new(false, content);
self.tasks().append(&task);
}
fn setup_actions(&self) {
// Create action from key "filter" and add to action group "win"
let action_filter = self.settings().create_action("filter");
self.add_action(&action_filter);
}
fn remove_done_tasks(&self) {
let tasks = self.tasks();
let mut position = 0;
while let Some(item) = tasks.item(position) {
// Get `TaskObject` from `glib::Object`
let task_object = item
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
if task_object.is_completed() {
tasks.remove(position);
} else {
position += 1;
}
}
}
}
When using boxed lists, you also have to take care to hide the ListBox
when there is no task present.
Filename: listings/todo/6/window/mod.rs
mod imp;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow};
use gio::Settings;
use glib::{clone, Object};
use gtk::{gio, glib, Align, CheckButton, CustomFilter, FilterListModel, NoSelection};
use crate::task_object::{TaskData, TaskObject};
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub fn new(app: &adw::Application) -> Self {
// Create new window
Object::builder().property("application", app).build()
}
fn setup_settings(&self) {
let settings = Settings::new(APP_ID);
self.imp()
.settings
.set(settings)
.expect("`settings` should not be set before calling `setup_settings`.");
}
fn settings(&self) -> &Settings {
self.imp()
.settings
.get()
.expect("`settings` should be set in `setup_settings`.")
}
fn tasks(&self) -> gio::ListStore {
self.imp()
.tasks
.borrow()
.clone()
.expect("Could not get current tasks.")
}
fn filter(&self) -> Option<CustomFilter> {
// Get filter state from settings
let filter_state: String = self.settings().get("filter");
// Create custom filters
let filter_open = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow completed tasks
!task_object.is_completed()
});
let filter_done = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow done tasks
task_object.is_completed()
});
// Return the correct filter
match filter_state.as_str() {
"All" => None,
"Open" => Some(filter_open),
"Done" => Some(filter_done),
_ => unreachable!(),
}
}
fn setup_tasks(&self) {
// Create new model
let model = gio::ListStore::new::<TaskObject>();
// Get state and set model
self.imp().tasks.replace(Some(model));
// Wrap model with filter and selection and pass it to the list box
let filter_model = FilterListModel::new(Some(self.tasks()), self.filter());
let selection_model = NoSelection::new(Some(filter_model.clone()));
self.imp().tasks_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let task_object = obj
.downcast_ref()
.expect("The object should be of type `TaskObject`.");
let row = window.create_task_row(task_object);
row.upcast()
}
),
);
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
#[weak]
filter_model,
move |_, _| {
filter_model.set_filter(window.filter().as_ref());
}
),
);
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&self.tasks());
self.tasks().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
}
/// Assure that `tasks_list` is only visible
/// if the number of tasks is greater than 0
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<TaskData> = serde_json::from_reader(file).expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<TaskData>` to `Vec<TaskObject>`
let task_objects: Vec<TaskObject> = backup_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
// Insert restored objects into model
self.tasks().extend_from_slice(&task_objects);
}
}
fn create_task_row(&self, task_object: &TaskObject) -> ActionRow {
// Create check button
let check_button = CheckButton::builder()
.valign(Align::Center)
.can_focus(false)
.build();
// Create row
let row = ActionRow::builder()
.activatable_widget(&check_button)
.build();
row.add_prefix(&check_button);
// Bind properties
task_object
.bind_property("completed", &check_button, "active")
.bidirectional()
.sync_create()
.build();
task_object
.bind_property("content", &row, "title")
.sync_create()
.build();
// Return row
row
}
fn setup_callbacks(&self) {
// Setup callback for activation of the entry
self.imp().entry.connect_activate(clone!(
#[weak(rename_to = window)]
self,
move |_| {
window.new_task();
}
));
// Setup callback for clicking (and the releasing) the icon of the entry
self.imp().entry.connect_icon_release(clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.new_task();
}
));
}
fn new_task(&self) {
// Get content from entry and clear it
let buffer = self.imp().entry.buffer();
let content = buffer.text().to_string();
if content.is_empty() {
return;
}
buffer.set_text("");
// Add new task to model
let task = TaskObject::new(false, content);
self.tasks().append(&task);
}
fn setup_actions(&self) {
// Create action from key "filter" and add to action group "win"
let action_filter = self.settings().create_action("filter");
self.add_action(&action_filter);
}
fn remove_done_tasks(&self) {
let tasks = self.tasks();
let mut position = 0;
while let Some(item) = tasks.item(position) {
// Get `TaskObject` from `glib::Object`
let task_object = item
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
if task_object.is_completed() {
tasks.remove(position);
} else {
position += 1;
}
}
}
}
Finally, we define the set_task_list_visible
method.
Filename: listings/todo/6/window/mod.rs
mod imp;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow};
use gio::Settings;
use glib::{clone, Object};
use gtk::{gio, glib, Align, CheckButton, CustomFilter, FilterListModel, NoSelection};
use crate::task_object::{TaskData, TaskObject};
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub fn new(app: &adw::Application) -> Self {
// Create new window
Object::builder().property("application", app).build()
}
fn setup_settings(&self) {
let settings = Settings::new(APP_ID);
self.imp()
.settings
.set(settings)
.expect("`settings` should not be set before calling `setup_settings`.");
}
fn settings(&self) -> &Settings {
self.imp()
.settings
.get()
.expect("`settings` should be set in `setup_settings`.")
}
fn tasks(&self) -> gio::ListStore {
self.imp()
.tasks
.borrow()
.clone()
.expect("Could not get current tasks.")
}
fn filter(&self) -> Option<CustomFilter> {
// Get filter state from settings
let filter_state: String = self.settings().get("filter");
// Create custom filters
let filter_open = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow completed tasks
!task_object.is_completed()
});
let filter_done = CustomFilter::new(|obj| {
// Get `TaskObject` from `glib::Object`
let task_object = obj
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
// Only allow done tasks
task_object.is_completed()
});
// Return the correct filter
match filter_state.as_str() {
"All" => None,
"Open" => Some(filter_open),
"Done" => Some(filter_done),
_ => unreachable!(),
}
}
fn setup_tasks(&self) {
// Create new model
let model = gio::ListStore::new::<TaskObject>();
// Get state and set model
self.imp().tasks.replace(Some(model));
// Wrap model with filter and selection and pass it to the list box
let filter_model = FilterListModel::new(Some(self.tasks()), self.filter());
let selection_model = NoSelection::new(Some(filter_model.clone()));
self.imp().tasks_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let task_object = obj
.downcast_ref()
.expect("The object should be of type `TaskObject`.");
let row = window.create_task_row(task_object);
row.upcast()
}
),
);
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
#[weak]
filter_model,
move |_, _| {
filter_model.set_filter(window.filter().as_ref());
}
),
);
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&self.tasks());
self.tasks().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
}
/// Assure that `tasks_list` is only visible
/// if the number of tasks is greater than 0
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<TaskData> = serde_json::from_reader(file).expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<TaskData>` to `Vec<TaskObject>`
let task_objects: Vec<TaskObject> = backup_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
// Insert restored objects into model
self.tasks().extend_from_slice(&task_objects);
}
}
fn create_task_row(&self, task_object: &TaskObject) -> ActionRow {
// Create check button
let check_button = CheckButton::builder()
.valign(Align::Center)
.can_focus(false)
.build();
// Create row
let row = ActionRow::builder()
.activatable_widget(&check_button)
.build();
row.add_prefix(&check_button);
// Bind properties
task_object
.bind_property("completed", &check_button, "active")
.bidirectional()
.sync_create()
.build();
task_object
.bind_property("content", &row, "title")
.sync_create()
.build();
// Return row
row
}
fn setup_callbacks(&self) {
// Setup callback for activation of the entry
self.imp().entry.connect_activate(clone!(
#[weak(rename_to = window)]
self,
move |_| {
window.new_task();
}
));
// Setup callback for clicking (and the releasing) the icon of the entry
self.imp().entry.connect_icon_release(clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.new_task();
}
));
}
fn new_task(&self) {
// Get content from entry and clear it
let buffer = self.imp().entry.buffer();
let content = buffer.text().to_string();
if content.is_empty() {
return;
}
buffer.set_text("");
// Add new task to model
let task = TaskObject::new(false, content);
self.tasks().append(&task);
}
fn setup_actions(&self) {
// Create action from key "filter" and add to action group "win"
let action_filter = self.settings().create_action("filter");
self.add_action(&action_filter);
}
fn remove_done_tasks(&self) {
let tasks = self.tasks();
let mut position = 0;
while let Some(item) = tasks.item(position) {
// Get `TaskObject` from `glib::Object`
let task_object = item
.downcast_ref::<TaskObject>()
.expect("The object needs to be of type `TaskObject`.");
if task_object.is_completed() {
tasks.remove(position);
} else {
position += 1;
}
}
}
}
This is how the boxed list style looks like in our app.