thedes_tui/
progress_bar.rs

1pub use style::Style;
2
3mod style;
4
5use std::panic;
6
7use crate::{
8    core::{
9        App,
10        event::{Event, Key, KeyEvent},
11        input,
12        screen::FlushError,
13    },
14    text,
15};
16use thedes_async_util::progress::{self, Progress};
17use thedes_tui_core::{geometry::Coord, mutation::Set, screen};
18use thiserror::Error;
19use tokio::task;
20
21pub fn default_key_bindings() -> KeyBindingMap {
22    let map = KeyBindingMap::new()
23        .with(Key::Esc, Command::Cancel)
24        .with(Key::Char('q'), Command::Cancel)
25        .with(Key::Char('Q'), Command::Cancel);
26    map
27}
28
29#[derive(Debug, Error)]
30pub enum Error {
31    #[error("Generator task was cancelled")]
32    Join(
33        #[source]
34        #[from]
35        task::JoinError,
36    ),
37    #[error("Failed to flush commands to screen")]
38    Flush(
39        #[from]
40        #[source]
41        FlushError,
42    ),
43    #[error("Failed to render text")]
44    Error(
45        #[from]
46        #[source]
47        text::Error,
48    ),
49    #[error("TUI cancelled")]
50    Cancelled,
51    #[error("Input driver was cancelled")]
52    InputCancelled(
53        #[source]
54        #[from]
55        input::ReadError,
56    ),
57}
58
59#[derive(Debug, Clone)]
60pub enum Command {
61    Cancel,
62}
63
64pub type KeyBindingMap = crate::key_bindings::KeyBindingMap<Command>;
65
66#[derive(Debug, Clone)]
67pub struct Component {
68    key_bindings: KeyBindingMap,
69    title: String,
70    style: Style,
71}
72
73impl Component {
74    pub fn new(title: impl AsRef<str>) -> Self {
75        Self {
76            title: title.as_ref().to_owned(),
77            style: Style::default(),
78            key_bindings: default_key_bindings(),
79        }
80    }
81
82    pub async fn run<A>(
83        &self,
84        app: &mut App,
85        monitor: progress::Monitor,
86        task: A,
87    ) -> Result<Option<A::Output>, Error>
88    where
89        A: Future + Send + 'static,
90        A::Output: Send + 'static,
91    {
92        let cancel_token = app.cancel_token.child_token();
93
94        let task = task::spawn({
95            let cancel_token = cancel_token.clone();
96            async move {
97                tokio::select! {
98                    output = task => Some(output),
99                    _ = cancel_token.cancelled() => None,
100                }
101            }
102        });
103
104        let should_continue = loop {
105            if !self.handle_input(app)? {
106                cancel_token.cancel();
107                break false;
108            }
109            let progress = monitor.read();
110            if progress.current() >= monitor.goal() {
111                break true;
112            }
113            self.render(app, &progress, monitor.goal())?;
114            app.canvas.flush()?;
115            tokio::select! {
116                _ = app.tick_session.tick() => (),
117                _ = app.cancel_token.cancelled() => Err(Error::Cancelled)?,
118            }
119        };
120
121        let item = match task.await {
122            Ok(item) => item,
123            Err(join_error) => match join_error.try_into_panic() {
124                Ok(payload) => panic::resume_unwind(payload),
125                Err(join_error) => Err(join_error)?,
126            },
127        };
128
129        Ok(item.filter(|_| should_continue))
130    }
131
132    fn handle_input(&self, app: &mut App) -> Result<bool, Error> {
133        let events: Vec<_> = app.events.read_until_now()?.collect();
134
135        for event in events {
136            match event {
137                Event::Key(key) => {
138                    if !self.handle_key(key)? {
139                        return Ok(false);
140                    }
141                },
142                Event::Paste(_) => (),
143            }
144        }
145
146        Ok(true)
147    }
148
149    fn handle_key(&self, key: KeyEvent) -> Result<bool, Error> {
150        if let Some(command) = self.key_bindings.command_for(key) {
151            match command {
152                Command::Cancel => return Ok(false),
153            }
154        }
155
156        Ok(true)
157    }
158
159    fn render(
160        &self,
161        app: &mut App,
162        progress: &Progress,
163        goal: usize,
164    ) -> Result<(), Error> {
165        app.canvas.queue([screen::Command::new_clear_screen(
166            self.style.background(),
167        )]);
168        self.render_title(app)?;
169        self.render_bar(app, progress, goal)?;
170        self.render_perc(app, progress, goal)?;
171        self.render_absolute(app, progress, goal)?;
172        self.render_status(app, progress)?;
173        Ok(())
174    }
175
176    fn render_title(&self, app: &mut App) -> Result<(), Error> {
177        let style = text::Style::default()
178            .with_align(1, 2)
179            .with_colors(Set(self.style.title_colors()))
180            .with_top_margin(self.style.title_y());
181        text::styled(app, &self.title, &style)?;
182        Ok(())
183    }
184
185    fn render_bar(
186        &self,
187        app: &mut App,
188        progress: &Progress,
189        goal: usize,
190    ) -> Result<(), Error> {
191        let style = text::Style::default()
192            .with_align(1, 2)
193            .with_colors(Set(self.style.bar_colors()))
194            .with_top_margin(self.y_of_bar());
195        let mut text = String::new();
196        let bar_size = usize::from(self.style.bar_size());
197        let normalized_progress = progress.current() * bar_size / goal;
198        let normalized_progress = normalized_progress as Coord;
199        for _ in 0 .. normalized_progress {
200            text.push_str("█");
201        }
202        for _ in normalized_progress .. self.style.bar_size() {
203            text.push_str(" ");
204        }
205        text::styled(app, &text, &style)?;
206        Ok(())
207    }
208
209    fn render_perc(
210        &self,
211        app: &mut App,
212        progress: &Progress,
213        goal: usize,
214    ) -> Result<(), Error> {
215        let style = text::Style::default()
216            .with_align(1, 2)
217            .with_colors(Set(self.style.perc_colors()))
218            .with_top_margin(self.y_of_perc());
219        let perc = progress.current() * 100 / goal;
220        let text = format!("{perc}%");
221        text::styled(app, &text, &style)?;
222        Ok(())
223    }
224
225    fn render_absolute(
226        &self,
227        app: &mut App,
228        progress: &Progress,
229        goal: usize,
230    ) -> Result<(), Error> {
231        let style = text::Style::default()
232            .with_align(1, 2)
233            .with_colors(Set(self.style.absolute_colors()))
234            .with_top_margin(self.y_of_absolute());
235        let text = format!("{current}/{goal}", current = progress.current());
236        text::styled(app, &text, &style)?;
237        Ok(())
238    }
239
240    fn render_status(
241        &self,
242        app: &mut App,
243        progress: &Progress,
244    ) -> Result<(), Error> {
245        let style = text::Style::default()
246            .with_align(1, 2)
247            .with_colors(Set(self.style.status_colors()))
248            .with_top_margin(self.y_of_status());
249        text::styled(app, progress.status(), &style)?;
250        Ok(())
251    }
252
253    fn y_of_bar(&self) -> Coord {
254        self.style.pad_after_title() + 1 + self.style.title_y()
255    }
256
257    fn y_of_perc(&self) -> Coord {
258        self.y_of_bar() + 1 + self.style.pad_after_bar()
259    }
260
261    fn y_of_absolute(&self) -> Coord {
262        self.y_of_perc() + 1 + self.style.pad_after_perc()
263    }
264
265    fn y_of_status(&self) -> Coord {
266        self.y_of_absolute() + 1 + self.style.pad_after_abs()
267    }
268}