Skip to main content

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` (`$icon`)
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(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
102    BlockPlan::new(vec![
103        OutputPlan::new(
104            "main",
105            config.format.with_default(" $icon $count.eng(w:1) ")?,
106        )
107        .icon("icon", IconChoices::one(icons::TASKS)),
108        OutputPlan::new(
109            "singular",
110            config
111                .format_singular
112                .with_default(" $icon $count.eng(w:1) ")?,
113        )
114        .icon("icon", IconChoices::one(icons::TASKS)),
115        OutputPlan::new(
116            "everything_done",
117            config
118                .format_everything_done
119                .with_default(" $icon $count.eng(w:1) ")?,
120        )
121        .icon("icon", IconChoices::one(icons::TASKS)),
122    ])
123}
124
125pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
126    let mut actions = api.get_actions()?;
127    api.set_default_actions(&[(MouseButton::Right, None, "next_filter")])?;
128
129    let output_main = plan.output("main")?;
130    let output_singular = plan.output("singular")?;
131    let output_everything_done = plan.output("everything_done")?;
132
133    let mut filters = config.filters.iter().cycle();
134    let mut filter = filters.next().error("`filters` is empty")?;
135
136    let notify = Inotify::init().error("Failed to start inotify")?;
137    notify
138        .watches()
139        .add(&*config.data_location.expand()?, WatchMask::MODIFY)
140        .error("Failed to watch data location")?;
141    let mut updates = notify
142        .into_event_stream([0; 1024])
143        .error("Failed to create event stream")?;
144
145    loop {
146        let number_of_tasks = get_number_of_tasks(filter).await?;
147
148        let output = match number_of_tasks {
149            0 => &output_everything_done,
150            1 => &output_singular,
151            _ => &output_main,
152        };
153        let mut widget = output.new_widget();
154
155        widget.set_values(map! {
156            "icon" => Value::icon(icons::TASKS),
157            "count" => Value::number(number_of_tasks),
158            "filter_name" => Value::text(filter.name.clone()),
159        });
160
161        widget.state = match number_of_tasks {
162            x if x >= config.critical_threshold => State::Critical,
163            x if x >= config.warning_threshold => State::Warning,
164            _ => State::Idle,
165        };
166
167        api.set_widget(widget)?;
168
169        loop {
170            select! {
171                _ = sleep(config.interval.0) => break,
172                Some(Ok(event)) = updates.next() => {
173                    // Skip SQLite journal files (-shm, -wal, -journal) to avoid
174                    // feedback loop with TaskWarrior v3's SQLite backend.
175                    // These files are modified on every read operation, which would
176                    // otherwise cause continuous updates.
177                    if let Some(name) = event.name {
178                        let name_str = name.to_string_lossy();
179                        if name_str.ends_with("-shm") || name_str.ends_with("-wal") || name_str.ends_with("-journal") {
180                            continue;
181                        }
182                    }
183                    break;
184                }
185                _ = api.wait_for_update_request() => break,
186                Some(action) = actions.recv() => {
187                    match action.as_ref() {
188                        "next_filter" => {
189                            filter = filters.next().unwrap();
190                        }
191                        _ => (),
192                    }
193                    break;
194                }
195            }
196        }
197    }
198}
199
200/// The `count` invocation must not have side effects on the task database:
201/// `rc.gc=off` keeps task IDs stable, and `rc.recurrence.limit=0` prevents
202/// the creation of new recurrence instances, which is racy when several
203/// `task` processes run concurrently (e.g. two taskwarrior blocks) and
204/// produced duplicate recurring tasks. These come after the user's
205/// `config_override`, so they always win.
206fn count_args(filter: &Filter) -> impl Iterator<Item = &str> {
207    filter.config_override.iter().map(String::as_str).chain([
208        "rc.gc=off",
209        "rc.recurrence.limit=0",
210        &filter.filter,
211        "count",
212    ])
213}
214
215async fn get_number_of_tasks(filter: &Filter) -> Result<u32> {
216    let output = Command::new("task")
217        .args(count_args(filter))
218        .output()
219        .await
220        .error("failed to run taskwarrior for getting the number of tasks")?
221        .stdout;
222    std::str::from_utf8(&output)
223        .error("failed to get the number of tasks from taskwarrior (invalid UTF-8)")?
224        .trim()
225        .parse::<u32>()
226        .error("could not parse the result of taskwarrior")
227}
228
229#[derive(Deserialize, Debug, Default, Clone)]
230#[serde(deny_unknown_fields)]
231pub struct Filter {
232    pub name: String,
233    pub filter: String,
234    #[serde(default)]
235    pub config_override: Vec<String>,
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn count_is_side_effect_free() {
244        let filter = Filter {
245            name: "pending".into(),
246            filter: "-COMPLETED -DELETED".into(),
247            config_override: vec!["rc.context=work".into()],
248        };
249        let args: Vec<&str> = count_args(&filter).collect();
250        assert_eq!(
251            args,
252            [
253                "rc.context=work",
254                "rc.gc=off",
255                "rc.recurrence.limit=0",
256                "-COMPLETED -DELETED",
257                "count",
258            ]
259        );
260        // the safety overrides must come after config_override so they win
261        let gc = args.iter().position(|a| *a == "rc.gc=off").unwrap();
262        let rec = args
263            .iter()
264            .position(|a| *a == "rc.recurrence.limit=0")
265            .unwrap();
266        let user = args.iter().position(|a| *a == "rc.context=work").unwrap();
267        assert!(user < gc && user < rec);
268    }
269
270    #[test]
271    fn plan_declares_count_states_with_tasks_icon() {
272        let plan = prepare(&Config::default()).unwrap();
273        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
274        assert_eq!(ids, ["main", "singular", "everything_done"]);
275        for id in ["main", "singular", "everything_done"] {
276            let output = plan.output(id).unwrap();
277            assert_eq!(output.single_icon("icon").unwrap(), "tasks", "{id}");
278        }
279    }
280
281    #[test]
282    fn each_count_state_resolves_its_own_format() {
283        // The "hide when everything is done" configuration from the docs:
284        // an empty format for that state only.
285        let config = Config {
286            format_everything_done: "".parse().unwrap(),
287            ..Config::default()
288        };
289        let plan = prepare(&config).unwrap();
290        assert!(
291            !plan
292                .output("everything_done")
293                .unwrap()
294                .format()
295                .contains_key("count")
296        );
297        for id in ["main", "singular"] {
298            assert!(
299                plan.output(id).unwrap().format().contains_key("count"),
300                "{id}"
301            );
302        }
303    }
304}