Skip to main content

qframe/widgets/
task_list.rs

1//! Rows showing background tasks: spinner, label, step, progress and outcome.
2
3use crate::runtime::{TaskId, TaskOutcome, Tasks};
4use crate::text;
5use crate::widget::{Length, NodeMut, View};
6use crate::widgets::{Button, ProgressBar, Spinner, Text};
7
8type CancelMessage<'a, Msg> = Box<dyn Fn(TaskId) -> Msg + 'a>;
9
10/// Width reserved for a progress bar with its percentage.
11const BAR_WIDTH: u16 = 22;
12
13/// Shows a [`Tasks`] model as rows built from existing widgets.
14///
15/// A running task shows a [`Spinner`], its label, its latest note and, when it reports a
16/// fraction, a [`ProgressBar`]. A finished task shows a status icon with a word: a success check
17/// with `done`, a danger cross with the failure reason, a muted dash with `cancelled`. Columns
18/// line up across rows. With no tasks it shows the empty text in faint type.
19///
20/// Plain by default; `on_cancel` adds a cancel button to running rows. Words come from
21/// `quvyta.tasks.done`, `quvyta.tasks.cancelled`, `quvyta.tasks.cancel` and
22/// `quvyta.tasks.empty`; icons `success`, `error` and `check-partial`.
23pub struct TaskList<'a, Msg> {
24    tasks: &'a Tasks,
25    empty: Option<String>,
26    on_cancel: Option<CancelMessage<'a, Msg>>,
27}
28
29impl<'a, Msg: Clone + 'static> TaskList<'a, Msg> {
30    /// Rows for `tasks`.
31    #[must_use]
32    pub fn new(tasks: &'a Tasks) -> Self {
33        Self { tasks, empty: None, on_cancel: None }
34    }
35
36    /// Text shown when there are no tasks, instead of `quvyta.tasks.empty`.
37    #[must_use]
38    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
39        self.empty = Some(text.into());
40        self
41    }
42
43    /// Adds a cancel button to every running task; the message usually returns
44    /// [`Command::cancel_task`](crate::runtime::Command::cancel_task).
45    #[must_use]
46    pub fn on_cancel(mut self, message: impl Fn(TaskId) -> Msg + 'a) -> Self {
47        self.on_cancel = Some(Box::new(message));
48        self
49    }
50
51    /// Adds the rows to `ui` as one column.
52    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
53        let i18n = ui.env().i18n();
54        let word = |key: &str| i18n.translate(&format!("quvyta.tasks.{key}"), &[]);
55        let (done, cancelled, cancel) = (word("done"), word("cancelled"), word("cancel"));
56        let empty = self.empty.clone().unwrap_or_else(|| word("empty"));
57        let cancel_width = text::width(&cancel).saturating_add(4);
58        let icons = ui.env().icons();
59        let glyphs = [
60            icons.glyph("success").into_owned(),
61            icons.glyph("error").into_owned(),
62            icons.glyph("check-partial").into_owned(),
63        ];
64        ui.column(|ui| {
65            if self.tasks.entries().is_empty() {
66                ui.add(Text::new(empty).role("faint"));
67            }
68            for entry in self.tasks.entries() {
69                let running = entry.outcome.is_none();
70                ui.row(|ui| {
71                    match &entry.outcome {
72                        None => ui.add(Spinner::new()),
73                        Some(TaskOutcome::Done) => ui.add(Text::new(glyphs[0].clone()).color("success").no_wrap()),
74                        Some(TaskOutcome::Failed(_)) => ui.add(Text::new(glyphs[1].clone()).color("danger").no_wrap()),
75                        Some(TaskOutcome::Cancelled) => ui.add(Text::new(glyphs[2].clone()).role("faint").no_wrap()),
76                    }
77                    .width(Length::Cells(1));
78                    let label_role = if running { "body" } else { "secondary" };
79                    ui.add(Text::new(entry.label.clone()).role(label_role).no_wrap()).width(Length::Fill(2));
80                    let status = match &entry.outcome {
81                        None => Text::new(entry.note.clone().unwrap_or_default()).role("faint"),
82                        Some(TaskOutcome::Done) => Text::new(done.clone()).color("success"),
83                        Some(TaskOutcome::Failed(reason)) => Text::new(reason.clone()).color("danger"),
84                        Some(TaskOutcome::Cancelled) => Text::new(cancelled.clone()).role("faint"),
85                    };
86                    ui.add(status.no_wrap()).width(Length::Fill(3));
87                    match entry.fraction.filter(|_| running) {
88                        Some(fraction) => ui.add(ProgressBar::new(fraction)),
89                        None => ui.spacer(),
90                    }
91                    .width(Length::Cells(BAR_WIDTH));
92                    if let Some(message) = &self.on_cancel {
93                        if running {
94                            ui.add(Button::new(cancel.clone()).on_press(message(entry.id)))
95                                .width(Length::Cells(cancel_width))
96                                .id("cancel");
97                        } else {
98                            ui.spacer().width(Length::Cells(cancel_width));
99                        }
100                    }
101                })
102                .gap(2)
103                .fill_width()
104                .id(format!("task-{:?}", entry.id));
105            }
106        })
107        .fill_width()
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use std::time::Duration;
114
115    use super::*;
116    use crate::runtime::{App, Command, Harness, Task, TaskEvent};
117
118    #[derive(Default)]
119    struct Deploys {
120        tasks: Tasks,
121        cancellable: bool,
122    }
123
124    #[derive(Clone)]
125    enum Msg {
126        Start(&'static str, bool),
127        Event(TaskEvent),
128        Cancel(TaskId),
129        Done,
130    }
131
132    impl App for Deploys {
133        type Msg = Msg;
134        fn update(&mut self, msg: Msg) -> Command<Msg> {
135            match msg {
136                Msg::Start(label, fail) => Command::task(
137                    Task::new(label, move |cx| {
138                        cx.note("pushing layers");
139                        for step in 1..=4 {
140                            if !cx.sleep(Duration::from_millis(100)) {
141                                return Err("stopped".into());
142                            }
143                            if fail && step == 2 {
144                                return Err("registry timed out".into());
145                            }
146                            cx.progress(step as f32 / 4.0);
147                        }
148                        Ok(Msg::Done)
149                    })
150                    .on_event(Msg::Event),
151                ),
152                Msg::Event(event) => {
153                    self.tasks.apply(&event);
154                    Command::none()
155                }
156                Msg::Cancel(id) => Command::cancel_task(id),
157                Msg::Done => Command::none(),
158            }
159        }
160        fn view(&self, ui: &mut View<'_, Msg>) {
161            let list = TaskList::new(&self.tasks);
162            let list = if self.cancellable { list.on_cancel(Msg::Cancel) } else { list };
163            list.show(ui);
164        }
165    }
166
167    fn deploys(cancellable: bool) -> Harness<Deploys> {
168        Harness::new(Deploys { tasks: Tasks::new(), cancellable }, 80, 3)
169    }
170
171    #[test]
172    fn shows_empty_running_and_finished_rows() {
173        let mut h = deploys(false);
174        assert_eq!(h.screen(), "No tasks running\n\n\n");
175        h.send(Msg::Start("api", true)).advance(Duration::from_millis(100));
176        let screen = h.screen();
177        assert!(screen.contains("api") && screen.contains("pushing layers") && screen.contains("25%"), "{screen}");
178        h.advance(Duration::from_millis(100));
179        let screen = h.screen();
180        assert!(screen.starts_with("✕  api"), "{screen}");
181        assert!(screen.contains("registry timed out"));
182        let (x, y) = h.find("registry").expect("reason shown");
183        let danger = h.env().theme().color("danger");
184        assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), danger);
185    }
186
187    #[test]
188    fn cancel_buttons_only_when_asked_and_only_on_running_rows() {
189        let mut plain = deploys(false);
190        plain.send(Msg::Start("web", false));
191        assert!(!plain.screen().contains("Cancel"));
192        let mut h = deploys(true);
193        h.send(Msg::Start("web", false)).advance(Duration::from_millis(100));
194        h.click_text("Cancel");
195        assert!(h.screen().contains("cancelled"), "{}", h.screen());
196        assert!(!h.screen().contains("Cancel "), "{}", h.screen());
197        assert_eq!(h.app().tasks.running(), 0);
198    }
199}