Skip to main content

zenity_rs/ui/
progress.rs

1//! Progress dialog implementation.
2
3use 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/// Progress dialog result.
29#[derive(Debug, Clone)]
30pub enum ProgressResult {
31    /// Progress completed (reached 100% or stdin closed).
32    Completed,
33    /// User cancelled the dialog.
34    Cancelled,
35    /// Dialog was closed.
36    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
49/// Message from stdin reader thread.
50enum StdinMessage {
51    Progress(u32),
52    Text(String),
53    Pulsate,
54    Done,
55}
56
57/// Progress dialog builder.
58pub 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        // First pass: calculate LOGICAL dimensions using scale 1.0
148        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        // Use custom dimensions if provided, otherwise use calculated defaults
165        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        // Create window with LOGICAL dimensions
169        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        // Get the actual scale factor from the window (compositor scale)
177        let scale = window.scale_factor();
178
179        // Now create everything at PHYSICAL scale
180        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        // Scale dimensions for physical rendering
188        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        // Calculate physical dimensions
193        let physical_width = (logical_width as f32 * scale) as u32;
194        let physical_height = (logical_height as f32 * scale) as u32;
195
196        // Create progress bar at physical scale
197        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        // Current status text
204        let mut status_text = self.text.clone();
205
206        // Time remaining calculation
207        let start_time = std::time::Instant::now();
208        let mut time_remaining_text = String::new();
209
210        // Position elements in physical coordinates
211        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        // Create canvas at PHYSICAL dimensions
224        let mut canvas = Canvas::new(physical_width, physical_height);
225
226        // Start stdin reader thread
227        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                    // Status text update
242                    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        // Draw function
261        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            // Draw status text
286            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            // Draw time remaining text
292            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            // Draw progress bar
306            progress_bar.draw(canvas, colors);
307
308            // Draw cancel button
309            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        // Initial draw
330        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        // Event loop with timeout for animation
349        let mut window_dragging = false;
350        loop {
351            let mut needs_redraw = false;
352
353            // Check for stdin messages
354            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            // Poll for window events (non-blocking if pulsating)
396            let event = if progress_bar.is_pulsating() {
397                // Use short timeout for animation
398                match window.poll_for_event()? {
399                    Some(e) => Some(e),
400                    None => {
401                        // Tick animation and redraw
402                        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                // Poll with short sleep to check stdin
423                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                // Process button events
450                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            // Redraw if needed (this ensures progress updates even when not focused)
466            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            // Short sleep to prevent CPU spinning when idle
484            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}