i3status_rs/blocks/taskwarrior.rs
1//! The number of tasks from the taskwarrior list
2//!
3//! Clicking the right mouse button on the icon cycles the view of the block through the user's filters.
4//!
5//! # Configuration
6//!
7//! Key | Values | Default
8//! ----|--------|--------
9//! `interval` | Update interval in seconds | `600` (10min)
10//! `warning_threshold` | The threshold of pending (or started) tasks when the block turns into a warning state | `10`
11//! `critical_threshold` | The threshold of pending (or started) tasks when the block turns into a critical state | `20`
12//! `filters` | A list of tables describing filters (see bellow) | ```[{name = "pending", filter = "-COMPLETED -DELETED"}]```
13//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $count.eng(w:1) "`
14//! `format_singular` | Same as `format` but for when exactly one task is pending. | `" $icon $count.eng(w:1) "`
15//! `format_everything_done` | Same as `format` but for when all tasks are completed. | `" $icon $count.eng(w:1) "`
16//! `data_location`| Directory in which taskwarrior stores its data files. Supports path expansions e.g. `~`. | `"~/.task"`
17//!
18//! ## Filter configuration
19//!
20//! Key | Values | Default
21//! ----|--------|--------
22//! `name` | The name of the filter |
23//! `filter` | Specifies the criteria that must be met for a task to be counted towards this filter |
24//! `config_override` | An array containing configuration overrides, useful for explicitly setting context or other configuration variables | `[]`
25//!
26//! # Placeholders
27//!
28//! Placeholder | Value | Type | Unit
29//! --------------|---------------------------------------------|--------|-----
30//! `icon` | A static icon | Icon | -
31//! `count` | The number of tasks matching current filter | Number | -
32//! `filter_name` | The name of current filter | Text | -
33//!
34//! # Actions
35//!
36//! Action | Default button
37//! --------------|---------------
38//! `next_filter` | Right
39//!
40//! # Example
41//!
42//! In this example, block will be hidden if `count` is zero.
43//!
44//! ```toml
45//! [[block]]
46//! block = "taskwarrior"
47//! interval = 60
48//! format = " $icon count.eng(w:1) tasks "
49//! format_singular = " $icon 1 task "
50//! format_everything_done = ""
51//! warning_threshold = 10
52//! critical_threshold = 20
53//! [[block.filters]]
54//! name = "today"
55//! filter = "+PENDING +OVERDUE or +DUETODAY"
56//! [[block.filters]]
57//! name = "some-project"
58//! filter = "project:some-project +PENDING"
59//! config_override = ["rc.context:none"]
60//! ```
61//!
62//! # Icons Used
63//! - `tasks`
64
65use super::prelude::*;
66use inotify::{Inotify, WatchMask};
67use tokio::process::Command;
68
69#[derive(Deserialize, Debug)]
70#[serde(deny_unknown_fields, default)]
71pub struct Config {
72 pub interval: Seconds,
73 pub warning_threshold: u32,
74 pub critical_threshold: u32,
75 pub filters: Vec<Filter>,
76 pub format: FormatConfig,
77 pub format_singular: FormatConfig,
78 pub format_everything_done: FormatConfig,
79 pub data_location: ShellString,
80}
81
82impl Default for Config {
83 fn default() -> Self {
84 Self {
85 interval: Seconds::new(600),
86 warning_threshold: 10,
87 critical_threshold: 20,
88 filters: vec![Filter {
89 name: "pending".into(),
90 filter: "-COMPLETED -DELETED".into(),
91 config_override: Default::default(),
92 }],
93 format: default(),
94 format_singular: default(),
95 format_everything_done: default(),
96 data_location: ShellString::new("~/.task"),
97 }
98 }
99}
100
101pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
102 let mut actions = api.get_actions()?;
103 api.set_default_actions(&[(MouseButton::Right, None, "next_filter")])?;
104
105 let format = config.format.with_default(" $icon $count.eng(w:1) ")?;
106 let format_singular = config
107 .format_singular
108 .with_default(" $icon $count.eng(w:1) ")?;
109 let format_everything_done = config
110 .format_everything_done
111 .with_default(" $icon $count.eng(w:1) ")?;
112
113 let mut filters = config.filters.iter().cycle();
114 let mut filter = filters.next().error("`filters` is empty")?;
115
116 let notify = Inotify::init().error("Failed to start inotify")?;
117 notify
118 .watches()
119 .add(&*config.data_location.expand()?, WatchMask::MODIFY)
120 .error("Failed to watch data location")?;
121 let mut updates = notify
122 .into_event_stream([0; 1024])
123 .error("Failed to create event stream")?;
124
125 loop {
126 let number_of_tasks = get_number_of_tasks(filter).await?;
127
128 let mut widget = Widget::new();
129
130 widget.set_format(match number_of_tasks {
131 0 => format_everything_done.clone(),
132 1 => format_singular.clone(),
133 _ => format.clone(),
134 });
135
136 widget.set_values(map! {
137 "icon" => Value::icon("tasks"),
138 "count" => Value::number(number_of_tasks),
139 "filter_name" => Value::text(filter.name.clone()),
140 });
141
142 widget.state = match number_of_tasks {
143 x if x >= config.critical_threshold => State::Critical,
144 x if x >= config.warning_threshold => State::Warning,
145 _ => State::Idle,
146 };
147
148 api.set_widget(widget)?;
149
150 loop {
151 select! {
152 _ = sleep(config.interval.0) => break,
153 Some(Ok(event)) = updates.next() => {
154 // Skip SQLite journal files (-shm, -wal, -journal) to avoid
155 // feedback loop with TaskWarrior v3's SQLite backend.
156 // These files are modified on every read operation, which would
157 // otherwise cause continuous updates.
158 if let Some(name) = event.name {
159 let name_str = name.to_string_lossy();
160 if name_str.ends_with("-shm") || name_str.ends_with("-wal") || name_str.ends_with("-journal") {
161 continue;
162 }
163 }
164 break;
165 }
166 _ = api.wait_for_update_request() => break,
167 Some(action) = actions.recv() => {
168 match action.as_ref() {
169 "next_filter" => {
170 filter = filters.next().unwrap();
171 }
172 _ => (),
173 }
174 break;
175 }
176 }
177 }
178 }
179}
180
181/// The `count` invocation must not have side effects on the task database:
182/// `rc.gc=off` keeps task IDs stable, and `rc.recurrence.limit=0` prevents
183/// the creation of new recurrence instances, which is racy when several
184/// `task` processes run concurrently (e.g. two taskwarrior blocks) and
185/// produced duplicate recurring tasks. These come after the user's
186/// `config_override`, so they always win.
187fn count_args(filter: &Filter) -> impl Iterator<Item = &str> {
188 filter.config_override.iter().map(String::as_str).chain([
189 "rc.gc=off",
190 "rc.recurrence.limit=0",
191 &filter.filter,
192 "count",
193 ])
194}
195
196async fn get_number_of_tasks(filter: &Filter) -> Result<u32> {
197 let output = Command::new("task")
198 .args(count_args(filter))
199 .output()
200 .await
201 .error("failed to run taskwarrior for getting the number of tasks")?
202 .stdout;
203 std::str::from_utf8(&output)
204 .error("failed to get the number of tasks from taskwarrior (invalid UTF-8)")?
205 .trim()
206 .parse::<u32>()
207 .error("could not parse the result of taskwarrior")
208}
209
210#[derive(Deserialize, Debug, Default, Clone)]
211#[serde(deny_unknown_fields)]
212pub struct Filter {
213 pub name: String,
214 pub filter: String,
215 #[serde(default)]
216 pub config_override: Vec<String>,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn count_is_side_effect_free() {
225 let filter = Filter {
226 name: "pending".into(),
227 filter: "-COMPLETED -DELETED".into(),
228 config_override: vec!["rc.context=work".into()],
229 };
230 let args: Vec<&str> = count_args(&filter).collect();
231 assert_eq!(
232 args,
233 [
234 "rc.context=work",
235 "rc.gc=off",
236 "rc.recurrence.limit=0",
237 "-COMPLETED -DELETED",
238 "count",
239 ]
240 );
241 // the safety overrides must come after config_override so they win
242 let gc = args.iter().position(|a| *a == "rc.gc=off").unwrap();
243 let rec = args
244 .iter()
245 .position(|a| *a == "rc.recurrence.limit=0")
246 .unwrap();
247 let user = args.iter().position(|a| *a == "rc.context=work").unwrap();
248 assert!(user < gc && user < rec);
249 }
250}