qframe/widgets/
task_list.rs1use 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
10const BAR_WIDTH: u16 = 22;
12
13pub 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 #[must_use]
32 pub fn new(tasks: &'a Tasks) -> Self {
33 Self { tasks, empty: None, on_cancel: None }
34 }
35
36 #[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 #[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 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}