1use std::{
4 io::{BufRead, BufReader},
5 sync::mpsc::{self, TryRecvError},
6 thread,
7 time::Duration,
8};
9
10#[cfg(unix)]
11use libc::{SIGTERM, getppid, kill};
12
13use crate::{
14 backend::{Window, WindowEvent, create_window},
15 error::Error,
16 render::{Canvas, Font},
17 ui::{
18 BASE_BUTTON_SPACING, BASE_CORNER_RADIUS, Colors,
19 widgets::{Widget, button::Button, progress_bar::ProgressBar},
20 },
21};
22
23const BASE_PADDING: u32 = 20;
24const BASE_BAR_WIDTH: u32 = 300;
25const BASE_TEXT_HEIGHT: u32 = 20;
26const BASE_BUTTON_HEIGHT: u32 = 32;
27
28#[derive(Debug, Clone)]
30pub enum ProgressResult {
31 Completed,
33 Cancelled,
35 Closed,
37}
38
39impl ProgressResult {
40 pub fn exit_code(&self) -> i32 {
41 match self {
42 ProgressResult::Completed => 0,
43 ProgressResult::Cancelled => 1,
44 ProgressResult::Closed => 1,
45 }
46 }
47}
48
49enum StdinMessage {
51 Progress(u32),
52 Text(String),
53 Pulsate,
54 Done,
55}
56
57pub struct ProgressBuilder {
59 title: String,
60 text: String,
61 percentage: u32,
62 pulsate: bool,
63 auto_close: bool,
64 auto_kill: bool,
65 no_cancel: bool,
66 show_time_remaining: bool,
67 width: Option<u32>,
68 height: Option<u32>,
69 colors: Option<&'static Colors>,
70}
71
72impl ProgressBuilder {
73 pub fn new() -> Self {
74 Self {
75 title: String::new(),
76 text: String::new(),
77 percentage: 0,
78 pulsate: false,
79 auto_close: false,
80 auto_kill: false,
81 no_cancel: false,
82 show_time_remaining: false,
83 width: None,
84 height: None,
85 colors: None,
86 }
87 }
88
89 pub fn title(mut self, title: &str) -> Self {
90 self.title = title.to_string();
91 self
92 }
93
94 pub fn text(mut self, text: &str) -> Self {
95 self.text = text.to_string();
96 self
97 }
98
99 pub fn percentage(mut self, percentage: u32) -> Self {
100 self.percentage = percentage.min(100);
101 self
102 }
103
104 pub fn pulsate(mut self, pulsate: bool) -> Self {
105 self.pulsate = pulsate;
106 self
107 }
108
109 pub fn auto_close(mut self, auto_close: bool) -> Self {
110 self.auto_close = auto_close;
111 self
112 }
113
114 pub fn auto_kill(mut self, auto_kill: bool) -> Self {
115 self.auto_kill = auto_kill;
116 self
117 }
118
119 pub fn colors(mut self, colors: &'static Colors) -> Self {
120 self.colors = Some(colors);
121 self
122 }
123
124 pub fn width(mut self, width: u32) -> Self {
125 self.width = Some(width);
126 self
127 }
128
129 pub fn height(mut self, height: u32) -> Self {
130 self.height = Some(height);
131 self
132 }
133
134 pub fn no_cancel(mut self, no_cancel: bool) -> Self {
135 self.no_cancel = no_cancel;
136 self
137 }
138
139 pub fn time_remaining(mut self, show_time_remaining: bool) -> Self {
140 self.show_time_remaining = show_time_remaining;
141 self
142 }
143
144 pub fn show(self) -> Result<ProgressResult, Error> {
145 let colors = self.colors.unwrap_or_else(|| crate::ui::detect_theme());
146
147 let temp_font = Font::load(1.0);
149 let temp_button = Button::new("Cancel", &temp_font, 1.0);
150 let temp_bar = ProgressBar::new(BASE_BAR_WIDTH, 1.0);
151
152 let calc_width = BASE_BAR_WIDTH + BASE_PADDING * 2;
153 let time_remaining_height = if self.show_time_remaining { 24 } else { 0 };
154 let calc_height = BASE_PADDING * 3
155 + BASE_TEXT_HEIGHT
156 + time_remaining_height
157 + 10
158 + temp_bar.height()
159 + 10
160 + BASE_BUTTON_HEIGHT;
161 drop(temp_font);
162 drop(temp_button);
163
164 let logical_width = self.width.unwrap_or(calc_width) as u16;
166 let logical_height = self.height.unwrap_or(calc_height) as u16;
167
168 let mut window = create_window(logical_width, logical_height)?;
170 window.set_title(if self.title.is_empty() {
171 "Progress"
172 } else {
173 &self.title
174 })?;
175
176 let scale = window.scale_factor();
178
179 let font = Font::load(scale);
181 let mut cancel_button = if self.no_cancel {
182 None
183 } else {
184 Some(Button::new("Cancel", &font, scale))
185 };
186
187 let padding = (BASE_PADDING as f32 * scale) as u32;
189 let bar_width = (BASE_BAR_WIDTH as f32 * scale) as u32;
190 let text_height = (BASE_TEXT_HEIGHT as f32 * scale) as u32;
191
192 let physical_width = (logical_width as f32 * scale) as u32;
194 let physical_height = (logical_height as f32 * scale) as u32;
195
196 let mut progress_bar = ProgressBar::new(bar_width, scale);
198 progress_bar.set_percentage(self.percentage);
199 if self.pulsate {
200 progress_bar.set_pulsating(true);
201 }
202
203 let mut status_text = self.text.clone();
205
206 let start_time = std::time::Instant::now();
208 let mut time_remaining_text = String::new();
209
210 let text_y = padding as i32;
212 let time_remaining_offset = if self.show_time_remaining { 24 } else { 0 };
213 let bar_y = text_y + text_height as i32 + 10 + time_remaining_offset;
214 progress_bar.set_position(padding as i32, bar_y);
215
216 let button_y =
217 bar_y + progress_bar.height() as i32 + (BASE_BUTTON_SPACING as f32 * scale) as i32;
218 if let Some(ref mut cancel_button) = cancel_button {
219 let button_x = physical_width as i32 - padding as i32 - cancel_button.width() as i32;
220 cancel_button.set_position(button_x, button_y);
221 }
222
223 let mut canvas = Canvas::new(physical_width, physical_height);
225
226 let (tx, rx) = mpsc::channel();
228 thread::spawn(move || {
229 let stdin = std::io::stdin();
230 let reader = BufReader::new(stdin.lock());
231
232 for line in reader.lines() {
233 let line = match line {
234 Ok(l) => l,
235 Err(_) => break,
236 };
237
238 let trimmed = line.trim();
239
240 if let Some(text) = trimmed.strip_prefix('#') {
241 let text = text.trim().to_string();
243 if tx.send(StdinMessage::Text(text)).is_err() {
244 break;
245 }
246 } else if trimmed.eq_ignore_ascii_case("pulsate") {
247 if tx.send(StdinMessage::Pulsate).is_err() {
248 break;
249 }
250 } else if let Ok(num) = trimmed.parse::<u32>() {
251 if tx.send(StdinMessage::Progress(num.min(100))).is_err() {
252 break;
253 }
254 }
255 }
256
257 let _ = tx.send(StdinMessage::Done);
258 });
259
260 let draw = |canvas: &mut Canvas,
262 colors: &Colors,
263 font: &Font,
264 status_text: &str,
265 time_remaining_text: &str,
266 progress_bar: &ProgressBar,
267 cancel_button: &Option<Button>,
268 padding: u32,
269 text_y: i32,
270 show_time_remaining: bool,
271 scale: f32| {
272 let width = canvas.width() as f32;
273 let height = canvas.height() as f32;
274 let radius = BASE_CORNER_RADIUS * scale;
275
276 canvas.fill_dialog_bg(
277 width,
278 height,
279 colors.window_bg,
280 colors.window_border,
281 colors.window_shadow,
282 radius,
283 );
284
285 if !status_text.is_empty() {
287 let text_canvas = font.render(status_text).with_color(colors.text).finish();
288 canvas.draw_canvas(&text_canvas, padding as i32, text_y);
289 }
290
291 if show_time_remaining && !time_remaining_text.is_empty() {
293 let text_canvas = font
294 .render(time_remaining_text)
295 .with_color(colors.text)
296 .finish();
297 let time_remaining_y = if !status_text.is_empty() {
298 text_y + 24
299 } else {
300 text_y
301 };
302 canvas.draw_canvas(&text_canvas, padding as i32, time_remaining_y);
303 }
304
305 progress_bar.draw(canvas, colors);
307
308 if let Some(button) = cancel_button {
310 button.draw_to(canvas, colors, font);
311 }
312 };
313
314 let format_time_remaining = |seconds: f64| -> String {
315 if seconds < 60.0 {
316 format!("{:.0}s remaining", seconds)
317 } else if seconds < 3600.0 {
318 let mins = (seconds / 60.0).floor();
319 let secs = seconds % 60.0;
320 format!("{:.0}m {:.0}s remaining", mins, secs)
321 } else {
322 let hours = (seconds / 3600.0).floor();
323 let mins = ((seconds % 3600.0) / 60.0).floor();
324 let secs = seconds % 60.0;
325 format!("{:.0}h {:.0}m {:.0}s remaining", hours, mins, secs)
326 }
327 };
328
329 draw(
331 &mut canvas,
332 colors,
333 &font,
334 &status_text,
335 &time_remaining_text,
336 &progress_bar,
337 &cancel_button,
338 padding,
339 text_y,
340 self.show_time_remaining,
341 scale,
342 );
343 window.set_contents(&canvas)?;
344 window.show()?;
345
346 let auto_close = self.auto_close;
347
348 let mut window_dragging = false;
350 loop {
351 let mut needs_redraw = false;
352
353 loop {
355 match rx.try_recv() {
356 Ok(StdinMessage::Progress(p)) => {
357 progress_bar.set_percentage(p);
358 if self.show_time_remaining && !self.pulsate && p > 0 {
359 let elapsed = start_time.elapsed().as_secs_f64();
360 let progress_fraction = p as f64 / 100.0;
361 let estimated_total = elapsed / progress_fraction;
362 let remaining = (estimated_total - elapsed).max(0.0);
363 time_remaining_text = format_time_remaining(remaining);
364 }
365 needs_redraw = true;
366 if p >= 100 && auto_close {
367 return Ok(ProgressResult::Completed);
368 }
369 }
370 Ok(StdinMessage::Text(t)) => {
371 status_text = t;
372 needs_redraw = true;
373 }
374 Ok(StdinMessage::Pulsate) => {
375 progress_bar.set_pulsating(true);
376 needs_redraw = true;
377 }
378 Ok(StdinMessage::Done) => {
379 needs_redraw = true;
380 if auto_close {
381 return Ok(ProgressResult::Completed);
382 }
383 }
384 Err(TryRecvError::Empty) => break,
385 Err(TryRecvError::Disconnected) => {
386 needs_redraw = true;
387 if auto_close {
388 return Ok(ProgressResult::Completed);
389 }
390 break;
391 }
392 }
393 }
394
395 let event = if progress_bar.is_pulsating() {
397 match window.poll_for_event()? {
399 Some(e) => Some(e),
400 None => {
401 progress_bar.tick();
403 draw(
404 &mut canvas,
405 colors,
406 &font,
407 &status_text,
408 &time_remaining_text,
409 &progress_bar,
410 &cancel_button,
411 padding,
412 text_y,
413 self.show_time_remaining,
414 scale,
415 );
416 window.set_contents(&canvas)?;
417 std::thread::sleep(Duration::from_millis(16));
418 continue;
419 }
420 }
421 } else {
422 window.poll_for_event()?
424 };
425
426 if let Some(event) = event {
427 match &event {
428 WindowEvent::CloseRequested => {
429 return Ok(ProgressResult::Closed);
430 }
431 WindowEvent::RedrawRequested => {
432 needs_redraw = true;
433 }
434 WindowEvent::CursorMove(_) => {
435 if window_dragging {
436 let _ = window.start_drag();
437 window_dragging = false;
438 }
439 }
440 WindowEvent::ButtonPress(crate::backend::MouseButton::Left, _) => {
441 window_dragging = true;
442 }
443 WindowEvent::ButtonRelease(crate::backend::MouseButton::Left, _) => {
444 window_dragging = false;
445 }
446 _ => {}
447 }
448
449 if let Some(ref mut cancel_button) = cancel_button {
451 cancel_button.process_event(&event);
452
453 if cancel_button.was_clicked() {
454 if self.auto_kill {
455 #[cfg(unix)]
456 unsafe {
457 kill(getppid(), SIGTERM);
458 }
459 }
460 return Ok(ProgressResult::Cancelled);
461 }
462 }
463 }
464
465 if needs_redraw {
467 draw(
468 &mut canvas,
469 colors,
470 &font,
471 &status_text,
472 &time_remaining_text,
473 &progress_bar,
474 &cancel_button,
475 padding,
476 text_y,
477 self.show_time_remaining,
478 scale,
479 );
480 window.set_contents(&canvas)?;
481 }
482
483 if !needs_redraw && !progress_bar.is_pulsating() {
485 std::thread::sleep(Duration::from_millis(50));
486 }
487 }
488 }
489}
490
491impl Default for ProgressBuilder {
492 fn default() -> Self {
493 Self::new()
494 }
495}