Adding Collections
Sidebar
Using Libadwaita on its own was already a big leap forward when it came to the look and feel of the To-Do app. Let us go one step further by adding a way to group tasks into collections. These collections will get their own sidebar on the left of the app. We will start by adding an empty sidebar without any functionality.
There are a couple of steps we have to go through to get to this state.
First, we have to replace gtk::ApplicationWindow
with adw::ApplicationWindow
.
The main difference between those two is that adw::ApplicationWindow
has no titlebar area.
That comes in handy when we build up our interface with adw::NavigationSplitView
.
In the screenshot above, the NavigationSplitView
adds a sidebar for the collection view to the left, while the task view occupies the space on the right.
When using adw::ApplicationWindow
the collection view and task view have their own adw::HeaderBar
and the separator spans over the whole window.
Filename: listings/todo/7/resources/window.ui
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<menu id="main-menu">
<!--Menu implementation-->
</menu>
<template class="TodoWindow" parent="AdwApplicationWindow">
<property name="title" translatable="yes">To-Do</property>
<property name="width-request">360</property>
<property name="height-request">200</property>
<child>
<object class="AdwBreakpoint">
<condition>max-width: 500sp</condition>
<setter object="split_view" property="collapsed">True</setter>
</object>
</child>
<property name="content">
<object class="AdwNavigationSplitView" id="split_view">
<property name="min-sidebar-width">200</property>
<property name="sidebar">
<object class="AdwNavigationPage">
<!--Collection view implementation-->
</object>
</property>
<property name="content">
<object class="AdwNavigationPage">
<!--Task view implementation-->
</object>
</property>
</object>
</property>
</template>
</interface>
NavigationSplitView
also helps with making your app adaptive/
As soon as the requested size is too small to fit all children at the same time, the splitview collapses, and starts behaving like a gtk::Stack
.
This means that it only displays one of its children at a time.
The adaptive behavior of the leaflet allows the To-Do app to work on smaller screen sizes (like e.g. phones) even with the added collection view.
We add the necessary UI elements for the collection view, such as a header bar with a button to add a new collection, as well as the list box collections_list
to display the collections later on.
We also add the style navigations-sidebar to collections_list
.
Filename: listings/todo/7/resources/window.ui
<object class="AdwNavigationPage">
<property name="title" bind-source="TodoWindow"
bind-property="title" bind-flags="sync-create" />
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<child type="start">
<object class="GtkToggleButton">
<property name="icon-name">list-add-symbolic</property>
<property name="tooltip-text" translatable="yes">New Collection</property>
<property name="action-name">win.new-collection</property>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="child">
<object class="GtkListBox" id="collections_list">
<style>
<class name="navigation-sidebar" />
</style>
</object>
</property>
</object>
</property>
</object>
We also add a header bar to the task view.
Filename: listings/todo/7/resources/window.ui
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Tasks</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">False</property>
<child type="end">
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">main-menu</property>
<property name="tooltip-text" translatable="yes">Main Menu</property>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="child">
<object class="AdwClamp">
<property name="maximum-size">400</property>
<property name="tightening-threshold">300</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="spacing">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>
</property>
</object>
</property>
</object>
We also have to adapt the window implementation.
For example, the parent type of our window is now adw::ApplicationWindow
instead of gtk::ApplicationWindow
.
Filename: listings/todo/7/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/Todo7/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;
// π changed
type ParentType = adw::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 {}
// Trait shared by all adwaita application windows
impl AdwApplicationWindowImpl for Window {}
That also means that we have to implement the trait AdwApplicationWindowImpl
.
Filename: listings/todo/7/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/Todo7/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;
// π changed
type ParentType = adw::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 {}
// Trait shared by all adwaita application windows
impl AdwApplicationWindowImpl for Window {}
Finally, we add adw::ApplicationWindow
to the ancestors of Window
in mod.rs
.
Filename: listings/todo/7/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>)
// π changed
@extends adw::ApplicationWindow, 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;
}
}
}
}
Placeholder Page
Even before we start to populate the collection view, we ought to think about a different challenge: the empty state of our To-Do app. Before, the empty state without a single task was quite okay. It was clear that you had to add tasks in the entry bar. However, now the situation is different. Users will have to add a collection first, and we have to make that clear. The GNOME HIG suggests to use a placeholder page for that. In our case, this placeholder page will be presented to the user if they open the app without any collections present.
We now wrap our UI in a gtk::Stack
.
One stack page describes the placeholder page, the other describes the main page.
We will later wire up the logic to display the correct stack page in the Rust code.
Filename: listings/todo/8/resources/window.ui
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<menu id="main-menu">
<!--Menu implementation-->
</menu>
<template class="TodoWindow" parent="AdwApplicationWindow">
<property name="title" translatable="yes">To-Do</property>
<property name="width-request">360</property>
<property name="height-request">200</property>
<child>
<object class="AdwBreakpoint">
<condition>max-width: 500sp</condition>
<setter object="split_view" property="collapsed">True</setter>
</object>
</child>
<property name="content">
<object class="GtkStack" id="stack">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">placeholder</property>
<property name="child">
<object class="GtkBox">
<!--Placeholder page implementation-->
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">main</property>
<property name="child">
<object class="AdwNavigationSplitView" id="split_view">
<!--Main page implementation-->
</object>
</property>
</object>
</child>
</object>
</property>
</template>
</interface>
In order to create the pageholder page as displayed before, we combine a flat header bar with adw::StatusPage
.
Filename: listings/todo/8/resources/window.ui
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkHeaderBar">
<style>
<class name="flat" />
</style>
</object>
</child>
<child>
<object class="GtkWindowHandle">
<property name="vexpand">True</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">checkbox-checked-symbolic</property>
<property name="title" translatable="yes">No Tasks</property>
<property name="description" translatable="yes">Create some tasks to start using the app.</property>
<property name="child">
<object class="GtkButton">
<property name="label" translatable="yes">_New Collection</property>
<property name="use-underline">True</property>
<property name="halign">center</property>
<property name="action-name">win.new-collection</property>
<style>
<class name="pill" />
<class name="suggested-action" />
</style>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
Collections
We still need a way to store our collections.
Just like we have already created TaskObject
, we will now introduce CollectionObject
.
It will have the members title
and tasks
, both of which will be exposed as properties.
As usual, the full implementation can be seen by clicking at the eye symbol at the top right of the snippet.
Filename: listings/todo/8/collection_object/imp.rs
use std::cell::RefCell;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::Properties;
use gtk::{gio, glib};
use std::cell::OnceCell;
// Object holding the state
#[derive(Properties, Default)]
#[properties(wrapper_type = super::CollectionObject)]
pub struct CollectionObject {
#[property(get, set)]
pub title: RefCell<String>,
#[property(get, set)]
pub tasks: OnceCell<gio::ListStore>,
}
// The central trait for subclassing a GObject
#[glib::object_subclass]
impl ObjectSubclass for CollectionObject {
const NAME: &'static str = "TodoCollectionObject";
type Type = super::CollectionObject;
}
// Trait shared by all GObjects
#[glib::derived_properties]
impl ObjectImpl for CollectionObject {}
We also add the struct CollectionData
to aid in serialization and deserialization.
Filename: listings/todo/8/collection_object/mod.rs
mod imp;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::Object;
use gtk::{gio, glib};
use serde::{Deserialize, Serialize};
use crate::task_object::{TaskData, TaskObject};
glib::wrapper! {
pub struct CollectionObject(ObjectSubclass<imp::CollectionObject>);
}
impl CollectionObject {
pub fn new(title: &str, tasks: gio::ListStore) -> Self {
Object::builder()
.property("title", title)
.property("tasks", tasks)
.build()
}
pub fn to_collection_data(&self) -> CollectionData {
let title = self.imp().title.borrow().clone();
let tasks_data = self
.tasks()
.iter::<TaskObject>()
.filter_map(Result::ok)
.map(|task_object| task_object.task_data())
.collect();
CollectionData { title, tasks_data }
}
pub fn from_collection_data(collection_data: CollectionData) -> Self {
let title = collection_data.title;
let tasks_to_extend: Vec<TaskObject> = collection_data
.tasks_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
let tasks = gio::ListStore::new::<TaskObject>();
tasks.extend_from_slice(&tasks_to_extend);
Self::new(&title, tasks)
}
}
#[derive(Default, Clone, Serialize, Deserialize)]
pub struct CollectionData {
pub title: String,
pub tasks_data: Vec<TaskData>,
}
Finally, we add methods to CollectionObject
in order to
- construct it with
new
, - easily access the tasks
ListStore
withtasks
and - convert to and from
CollectionData
withto_collection_data
andfrom_collection_data
.
Filename: listings/todo/8/collection_object/mod.rs
mod imp;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::Object;
use gtk::{gio, glib};
use serde::{Deserialize, Serialize};
use crate::task_object::{TaskData, TaskObject};
glib::wrapper! {
pub struct CollectionObject(ObjectSubclass<imp::CollectionObject>);
}
impl CollectionObject {
pub fn new(title: &str, tasks: gio::ListStore) -> Self {
Object::builder()
.property("title", title)
.property("tasks", tasks)
.build()
}
pub fn to_collection_data(&self) -> CollectionData {
let title = self.imp().title.borrow().clone();
let tasks_data = self
.tasks()
.iter::<TaskObject>()
.filter_map(Result::ok)
.map(|task_object| task_object.task_data())
.collect();
CollectionData { title, tasks_data }
}
pub fn from_collection_data(collection_data: CollectionData) -> Self {
let title = collection_data.title;
let tasks_to_extend: Vec<TaskObject> = collection_data
.tasks_data
.into_iter()
.map(TaskObject::from_task_data)
.collect();
let tasks = gio::ListStore::new::<TaskObject>();
tasks.extend_from_slice(&tasks_to_extend);
Self::new(&title, tasks)
}
}
#[derive(Default, Clone, Serialize, Deserialize)]
pub struct CollectionData {
pub title: String,
pub tasks_data: Vec<TaskData>,
}
Window
In order to hook up the new logic, we have to add more state to imp::Window
.
There are additional widgets that we access via the template_child
macro.
Additionally, we reference the collections
list store, the current_collection
as well as the current_filter_model
.
We also store tasks_changed_handler_id
.
Its purpose will become clear in later snippets.
Filename: listings/todo/8/window/imp.rs
use std::cell::RefCell;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, NavigationSplitView};
use gio::Settings;
use glib::subclass::InitializingObject;
use gtk::glib::SignalHandlerId;
use gtk::{gio, glib, CompositeTemplate, Entry, FilterListModel, ListBox, Stack};
use std::cell::OnceCell;
use crate::collection_object::{CollectionData, CollectionObject};
use crate::utils::data_path;
// Object holding the state
#[derive(CompositeTemplate, Default)]
#[template(resource = "/org/gtk_rs/Todo8/window.ui")]
pub struct Window {
pub settings: OnceCell<Settings>,
#[template_child]
pub entry: TemplateChild<Entry>,
#[template_child]
pub tasks_list: TemplateChild<ListBox>,
// π all members below are new
#[template_child]
pub collections_list: TemplateChild<ListBox>,
#[template_child]
pub split_view: TemplateChild<NavigationSplitView>,
#[template_child]
pub stack: TemplateChild<Stack>,
pub collections: OnceCell<gio::ListStore>,
pub current_collection: RefCell<Option<CollectionObject>>,
pub current_filter_model: RefCell<Option<FilterListModel>>,
pub tasks_changed_handler_id: RefCell<Option<SignalHandlerId>>,
}
// 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 = adw::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();
});
// Create async action to create new collection and add to action group "win"
klass.install_action_async(
"win.new-collection",
None,
|window, _, _| async move {
window.new_collection().await;
},
);
}
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_collections();
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<CollectionData> = self
.obj()
.collections()
.iter::<CollectionObject>()
.filter_map(|collection_object| collection_object.ok())
.map(|collection_object| collection_object.to_collection_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 {}
// Trait shared by all adwaita application windows
impl AdwApplicationWindowImpl for Window {}
Further, we add a couple of helper methods which will come in handy later on.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
As always, we want our data to be saved when we close the window.
Since most of the implementation is in the method CollectionObject::to_collection_data
, the implementation of close_request
doesn't change much.
Filename: listings/todo/8/window/imp.rs
use std::cell::RefCell;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, NavigationSplitView};
use gio::Settings;
use glib::subclass::InitializingObject;
use gtk::glib::SignalHandlerId;
use gtk::{gio, glib, CompositeTemplate, Entry, FilterListModel, ListBox, Stack};
use std::cell::OnceCell;
use crate::collection_object::{CollectionData, CollectionObject};
use crate::utils::data_path;
// Object holding the state
#[derive(CompositeTemplate, Default)]
#[template(resource = "/org/gtk_rs/Todo8/window.ui")]
pub struct Window {
pub settings: OnceCell<Settings>,
#[template_child]
pub entry: TemplateChild<Entry>,
#[template_child]
pub tasks_list: TemplateChild<ListBox>,
// π all members below are new
#[template_child]
pub collections_list: TemplateChild<ListBox>,
#[template_child]
pub split_view: TemplateChild<NavigationSplitView>,
#[template_child]
pub stack: TemplateChild<Stack>,
pub collections: OnceCell<gio::ListStore>,
pub current_collection: RefCell<Option<CollectionObject>>,
pub current_filter_model: RefCell<Option<FilterListModel>>,
pub tasks_changed_handler_id: RefCell<Option<SignalHandlerId>>,
}
// 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 = adw::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();
});
// Create async action to create new collection and add to action group "win"
klass.install_action_async(
"win.new-collection",
None,
|window, _, _| async move {
window.new_collection().await;
},
);
}
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_collections();
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<CollectionData> = self
.obj()
.collections()
.iter::<CollectionObject>()
.filter_map(|collection_object| collection_object.ok())
.map(|collection_object| collection_object.to_collection_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 {}
// Trait shared by all adwaita application windows
impl AdwApplicationWindowImpl for Window {}
constructed
stays mostly the same as well.
Instead of setup_tasks
we now call setup_collections
.
Filename: listings/todo/8/window/imp.rs
use std::cell::RefCell;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, NavigationSplitView};
use gio::Settings;
use glib::subclass::InitializingObject;
use gtk::glib::SignalHandlerId;
use gtk::{gio, glib, CompositeTemplate, Entry, FilterListModel, ListBox, Stack};
use std::cell::OnceCell;
use crate::collection_object::{CollectionData, CollectionObject};
use crate::utils::data_path;
// Object holding the state
#[derive(CompositeTemplate, Default)]
#[template(resource = "/org/gtk_rs/Todo8/window.ui")]
pub struct Window {
pub settings: OnceCell<Settings>,
#[template_child]
pub entry: TemplateChild<Entry>,
#[template_child]
pub tasks_list: TemplateChild<ListBox>,
// π all members below are new
#[template_child]
pub collections_list: TemplateChild<ListBox>,
#[template_child]
pub split_view: TemplateChild<NavigationSplitView>,
#[template_child]
pub stack: TemplateChild<Stack>,
pub collections: OnceCell<gio::ListStore>,
pub current_collection: RefCell<Option<CollectionObject>>,
pub current_filter_model: RefCell<Option<FilterListModel>>,
pub tasks_changed_handler_id: RefCell<Option<SignalHandlerId>>,
}
// 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 = adw::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();
});
// Create async action to create new collection and add to action group "win"
klass.install_action_async(
"win.new-collection",
None,
|window, _, _| async move {
window.new_collection().await;
},
);
}
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_collections();
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<CollectionData> = self
.obj()
.collections()
.iter::<CollectionObject>()
.filter_map(|collection_object| collection_object.ok())
.map(|collection_object| collection_object.to_collection_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 {}
// Trait shared by all adwaita application windows
impl AdwApplicationWindowImpl for Window {}
setup_collections
sets up the collections
list store as well as assuring that changes in the model will be reflected in the collections_list
.
To do that it uses the method create_collection_row
.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
create_collection_row
takes a CollectionObject
and builds a gtk::ListBoxRow
from its information.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
We also adapt restore_data
.
Again, the heavy lifting comes from CollectionObject::from_collection_data
, so we don't have to change too much here.
Since the rows of collections_list
can be selected, we have to select one of them after restoring the data.
We choose the first one and let the method set_current_collection
do the rest.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
set_current_collection
assures that all elements accessing tasks refer to the task model of the current collection.
We bind the tasks_list
to the current collection and store the filter model.
Whenever there are no tasks in our current collection we want to hide our tasks list.
Otherwise, the list box will leave a bad-looking line behind.
However, we don't want to accumulate signal handlers whenever we switch collections.
This is why we store the tasks_changed_handler_id
and disconnect the old handler as soon as we set a new collection.
Finally, we select the collection row.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
Previously, we used the method set_task_list_visible
.
It assures that tasks_list
is only visible if the number of tasks is greater than 0.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
select_collection_row
assures that the row for the current collection is selected in collections_list
.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
Message Dialog
There isn't yet a way to add a collection. Let's implement that functionality.
The screencast above demonstrates the desired behavior.
When we activate the button with the +
symbol, a dialog appears.
While the entry is empty, the "Create" button remains insensitive.
As soon as we start typing, the button becomes sensitive.
When we remove all typed letters and the entry becomes empty again, the "Create" button becomes insensitive and the entry gets the "error" style.
After clicking the "Create" button, a new collection is created, and we navigate to its task view.
To implement that behavior we will first add a "new-collection" action to class_init
method.
This action will be activated by a click on the +
button as well as on the button in the placeholder page.
We are using install_action_async
.
It is a convenient way to add asynchronous actions to subclassed widgets.
Filename: listings/todo/8/window/imp.rs
use std::cell::RefCell;
use std::fs::File;
use adw::subclass::prelude::*;
use adw::{prelude::*, NavigationSplitView};
use gio::Settings;
use glib::subclass::InitializingObject;
use gtk::glib::SignalHandlerId;
use gtk::{gio, glib, CompositeTemplate, Entry, FilterListModel, ListBox, Stack};
use std::cell::OnceCell;
use crate::collection_object::{CollectionData, CollectionObject};
use crate::utils::data_path;
// Object holding the state
#[derive(CompositeTemplate, Default)]
#[template(resource = "/org/gtk_rs/Todo8/window.ui")]
pub struct Window {
pub settings: OnceCell<Settings>,
#[template_child]
pub entry: TemplateChild<Entry>,
#[template_child]
pub tasks_list: TemplateChild<ListBox>,
// π all members below are new
#[template_child]
pub collections_list: TemplateChild<ListBox>,
#[template_child]
pub split_view: TemplateChild<NavigationSplitView>,
#[template_child]
pub stack: TemplateChild<Stack>,
pub collections: OnceCell<gio::ListStore>,
pub current_collection: RefCell<Option<CollectionObject>>,
pub current_filter_model: RefCell<Option<FilterListModel>>,
pub tasks_changed_handler_id: RefCell<Option<SignalHandlerId>>,
}
// 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 = adw::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();
});
// Create async action to create new collection and add to action group "win"
klass.install_action_async(
"win.new-collection",
None,
|window, _, _| async move {
window.new_collection().await;
},
);
}
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_collections();
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<CollectionData> = self
.obj()
.collections()
.iter::<CollectionObject>()
.filter_map(|collection_object| collection_object.ok())
.map(|collection_object| collection_object.to_collection_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 {}
// Trait shared by all adwaita application windows
impl AdwApplicationWindowImpl for Window {}
As soon as the "new-collection" action is activated, the async
new_collection
method is called.
Here, we create the adw::AlertDialog
, set up the buttons as well as add the entry to it.
We add a callback to the entry to ensure that when the content changes, an empty content sets dialog_button
as insensitive and adds an "error" CSS class to the entry.
We then await
on the user pressing a button on the dialog.
If they click "Cancel", we simply return.
However, if they click "Create", we want a new collection to be created and set as current collection.
Afterwards we navigate forward on our leaflet, which means we navigate to the task view.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
We also add more callbacks to setup_callbacks
.
Importantly, we want to filter our current task model whenever the value of the "filter" setting changes.
Whenever the items of our collections change we also want to set the stack.
This makes sure that our placeholder page is shown if there are no collections.
Finally, we assure that when we click on a row of collections_list
, current_collection
is set to the selected collection and the split view shows the task view.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
Before, we called the method set_stack
.
This method ensure when there is at least one collection, the "main" page is shown, and the "placeholder" page otherwise.
Filename: listings/todo/8/window/mod.rs
mod imp;
use std::fs::File;
use adw::prelude::*;
use adw::subclass::prelude::*;
use adw::{ActionRow, AlertDialog, ResponseAppearance};
use gio::Settings;
use glib::{clone, Object};
use gtk::{
gio, glib, pango, Align, CheckButton, CustomFilter, Entry, FilterListModel, Label,
ListBoxRow, NoSelection,
};
use crate::collection_object::{CollectionData, CollectionObject};
use crate::task_object::TaskObject;
use crate::utils::data_path;
use crate::APP_ID;
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, 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.current_collection().tasks()
}
fn current_collection(&self) -> CollectionObject {
self.imp()
.current_collection
.borrow()
.clone()
.expect("`current_collection` should be set in `set_current_collections`.")
}
fn collections(&self) -> gio::ListStore {
self.imp()
.collections
.get()
.expect("`collections` should be set in `setup_collections`.")
.clone()
}
fn set_filter(&self) {
self.imp()
.current_filter_model
.borrow()
.clone()
.expect("`current_filter_model` should be set in `set_current_collection`.")
.set_filter(self.filter().as_ref());
}
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_collections(&self) {
let collections = gio::ListStore::new::<CollectionObject>();
self.imp()
.collections
.set(collections.clone())
.expect("Could not set collections");
self.imp().collections_list.bind_model(
Some(&collections),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let collection_object = obj
.downcast_ref()
.expect("The object should be of type `CollectionObject`.");
let row = window.create_collection_row(collection_object);
row.upcast()
}
),
)
}
fn restore_data(&self) {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let backup_data: Vec<CollectionData> = serde_json::from_reader(file)
.expect(
"It should be possible to read `backup_data` from the json file.",
);
// Convert `Vec<CollectionData>` to `Vec<CollectionObject>`
let collections: Vec<CollectionObject> = backup_data
.into_iter()
.map(CollectionObject::from_collection_data)
.collect();
// Insert restored objects into model
self.collections().extend_from_slice(&collections);
// Set first collection as current
if let Some(first_collection) = collections.first() {
self.set_current_collection(first_collection.clone());
}
}
}
fn create_collection_row(
&self,
collection_object: &CollectionObject,
) -> ListBoxRow {
let label = Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.build();
collection_object
.bind_property("title", &label, "label")
.sync_create()
.build();
ListBoxRow::builder().child(&label).build()
}
fn set_current_collection(&self, collection: CollectionObject) {
// Wrap model with filter and selection and pass it to the list box
let tasks = collection.tasks();
let filter_model = FilterListModel::new(Some(tasks.clone()), 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()
}
),
);
// Store filter model
self.imp().current_filter_model.replace(Some(filter_model));
// If present, disconnect old `tasks_changed` handler
if let Some(handler_id) = self.imp().tasks_changed_handler_id.take() {
self.tasks().disconnect(handler_id);
}
// Assure that the task list is only visible when it is supposed to
self.set_task_list_visible(&tasks);
let tasks_changed_handler_id = tasks.connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |tasks, _, _, _| {
window.set_task_list_visible(tasks);
}
));
self.imp()
.tasks_changed_handler_id
.replace(Some(tasks_changed_handler_id));
// Set current tasks
self.imp().current_collection.replace(Some(collection));
self.select_collection_row();
}
fn set_task_list_visible(&self, tasks: &gio::ListStore) {
self.imp().tasks_list.set_visible(tasks.n_items() > 0);
}
fn select_collection_row(&self) {
if let Some(index) = self.collections().find(&self.current_collection()) {
let row = self.imp().collections_list.row_at_index(index as i32);
self.imp().collections_list.select_row(row.as_ref());
}
}
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();
}
));
// Filter model whenever the value of the key "filter" changes
self.settings().connect_changed(
Some("filter"),
clone!(
#[weak(rename_to = window)]
self,
move |_, _| {
window.set_filter();
}
),
);
// Setup callback when items of collections change
self.set_stack();
self.collections().connect_items_changed(clone!(
#[weak(rename_to = window)]
self,
move |_, _, _, _| {
window.set_stack();
}
));
// Setup callback for activating a row of collections list
self.imp().collections_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let index = row.index();
let selected_collection = window
.collections()
.item(index as u32)
.expect("There needs to be an object at this position.")
.downcast::<CollectionObject>()
.expect("The object needs to be a `CollectionObject`.");
window.set_current_collection(selected_collection);
window.imp().split_view.set_show_content(true);
}
));
}
fn set_stack(&self) {
if self.collections().n_items() > 0 {
self.imp().stack.set_visible_child_name("main");
} else {
self.imp().stack.set_visible_child_name("placeholder");
}
}
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;
}
}
}
async fn new_collection(&self) {
// Create entry
let entry = Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = AlertDialog::builder()
.heading("New Collection")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog
.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(self).await;
// Return if the user chose `cancel_response`
if response == cancel_response {
return;
}
// Create a new list store
let tasks = gio::ListStore::new::<TaskObject>();
// Create a new collection object from the title the user provided
let title = entry.text().to_string();
let collection = CollectionObject::new(&title, tasks);
// Add new collection object and set current tasks
self.collections().append(&collection);
self.set_current_collection(collection);
// Show the content
self.imp().split_view.set_show_content(true);
}
}
And that was it! Now we can enjoy the final result.
You might have noticed that there is not yet a way to remove a collection. Try to implement this missing piece of functionality in your local version of the To-Do app. Which edge cases do you have to consider?