Skip to main content

par_term/
progress_bar.rs

1//! Progress bar overlay rendering using egui.
2//!
3//! Renders progress bars from OSC 9;4 (simple) and OSC 934 (named/concurrent)
4//! protocols as thin bar overlays at the top or bottom of the terminal window.
5
6use crate::config::{Config, ProgressBarPosition, ProgressBarStyle};
7pub use par_term_emu_core_rust::terminal::NamedProgressBar;
8use par_term_emu_core_rust::terminal::{ProgressBar, ProgressState};
9use std::collections::HashMap;
10
11/// Snapshot of all active progress bars for rendering.
12///
13/// Captured from the terminal before the mutable renderer borrow
14/// to avoid lock contention during egui rendering.
15#[derive(Debug, Clone)]
16pub struct ProgressBarSnapshot {
17    /// Simple progress bar (OSC 9;4)
18    pub simple: ProgressBar,
19    /// Named progress bars (OSC 934)
20    pub named: HashMap<String, NamedProgressBar>,
21}
22
23impl ProgressBarSnapshot {
24    /// Check if any progress bar is active
25    pub fn has_active(&self) -> bool {
26        self.simple.is_active() || self.named.values().any(|b| b.state.is_active())
27    }
28}
29
30/// Render progress bar overlays using egui.
31pub fn render_progress_bars(
32    ctx: &egui::Context,
33    snapshot: &ProgressBarSnapshot,
34    config: &Config,
35    window_width: f32,
36    window_height: f32,
37) {
38    if !config.progress_bar_enabled || !snapshot.has_active() {
39        return;
40    }
41
42    let bar_height = config.progress_bar_height;
43    let alpha = (config.progress_bar_opacity * 255.0) as u8;
44
45    // Calculate Y position based on config
46    let base_y = match config.progress_bar_position {
47        ProgressBarPosition::Top => 0.0,
48        ProgressBarPosition::Bottom => window_height - bar_height,
49    };
50
51    // Collect all active bars: simple bar first, then named bars sorted by ID
52    let mut bars: Vec<BarRenderInfo> = Vec::new();
53
54    if snapshot.simple.is_active() {
55        bars.push(BarRenderInfo {
56            state: snapshot.simple.state,
57            percent: snapshot.simple.progress,
58            label: None,
59        });
60    }
61
62    let mut named_sorted: Vec<_> = snapshot
63        .named
64        .values()
65        .filter(|b| b.state.is_active())
66        .collect();
67    named_sorted.sort_by(|a, b| a.id.cmp(&b.id));
68    for bar in named_sorted {
69        bars.push(BarRenderInfo {
70            state: bar.state,
71            percent: bar.percent,
72            label: bar.label.as_deref(),
73        });
74    }
75
76    if bars.is_empty() {
77        return;
78    }
79
80    // For multiple bars, stack them (each gets its own row)
81    let total_height = bar_height * bars.len() as f32;
82    let stacked_y = match config.progress_bar_position {
83        ProgressBarPosition::Top => base_y,
84        ProgressBarPosition::Bottom => window_height - total_height,
85    };
86
87    egui::Area::new(egui::Id::new("progress_bar_overlay"))
88        .fixed_pos(egui::pos2(0.0, stacked_y))
89        .order(egui::Order::Foreground)
90        .interactable(false)
91        .show(ctx, |ui| {
92            let painter = ui.painter();
93
94            for (i, bar) in bars.iter().enumerate() {
95                let y_offset = i as f32 * bar_height;
96                let bar_y = stacked_y + y_offset;
97
98                let color = state_color(bar.state, config, alpha);
99                let bg_color = egui::Color32::from_rgba_unmultiplied(0, 0, 0, alpha / 2);
100
101                // Draw background track
102                painter.rect_filled(
103                    egui::Rect::from_min_size(
104                        egui::pos2(0.0, bar_y),
105                        egui::vec2(window_width, bar_height),
106                    ),
107                    0.0,
108                    bg_color,
109                );
110
111                if bar.state == ProgressState::Indeterminate {
112                    // Animated indeterminate bar: full-width cycling gradient
113                    let time = ctx.input(|i| i.time) as f32;
114                    let segments = (window_width / 2.0).max(64.0) as usize;
115                    let seg_width = window_width / segments as f32;
116
117                    for s in 0..segments {
118                        let t = s as f32 / segments as f32;
119                        // Scrolling sine wave: two bright bands cycling across
120                        let phase = (t * std::f32::consts::TAU * 2.0) - (time * 3.0);
121                        let brightness = phase.sin() * 0.5 + 0.5; // 0..1
122                        let seg_alpha = (alpha as f32 * (0.25 + 0.75 * brightness)) as u8;
123                        let seg_color = egui::Color32::from_rgba_unmultiplied(
124                            color.r(),
125                            color.g(),
126                            color.b(),
127                            seg_alpha,
128                        );
129                        painter.rect_filled(
130                            egui::Rect::from_min_size(
131                                egui::pos2(s as f32 * seg_width, bar_y),
132                                egui::vec2(seg_width + 1.0, bar_height),
133                            ),
134                            0.0,
135                            seg_color,
136                        );
137                    }
138                    ctx.request_repaint();
139                } else {
140                    // Determinate bar: fill based on percentage
141                    let fill_width = window_width * (bar.percent as f32 / 100.0);
142                    painter.rect_filled(
143                        egui::Rect::from_min_size(
144                            egui::pos2(0.0, bar_y),
145                            egui::vec2(fill_width, bar_height),
146                        ),
147                        0.0,
148                        color,
149                    );
150                }
151
152                // Draw text overlay if style requires it
153                if config.progress_bar_style == ProgressBarStyle::BarWithText && bar_height >= 10.0
154                {
155                    let text = if let Some(label) = bar.label {
156                        if bar.state == ProgressState::Indeterminate {
157                            label.to_string()
158                        } else {
159                            format!("{} {}%", label, bar.percent)
160                        }
161                    } else if bar.state == ProgressState::Indeterminate {
162                        String::new()
163                    } else {
164                        format!("{}%", bar.percent)
165                    };
166
167                    if !text.is_empty() {
168                        let font_size = (bar_height - 2.0).clamp(8.0, 12.0);
169                        let font_id = egui::FontId::new(font_size, egui::FontFamily::Proportional);
170                        let text_color = egui::Color32::WHITE;
171                        painter.text(
172                            egui::pos2(6.0, bar_y + bar_height / 2.0),
173                            egui::Align2::LEFT_CENTER,
174                            &text,
175                            font_id,
176                            text_color,
177                        );
178                    }
179                }
180            }
181        });
182}
183
184/// Info needed to render a single progress bar.
185struct BarRenderInfo<'a> {
186    state: ProgressState,
187    percent: u8,
188    label: Option<&'a str>,
189}
190
191/// Get the color for a progress state from config.
192fn state_color(state: ProgressState, config: &Config, alpha: u8) -> egui::Color32 {
193    let rgb = match state {
194        ProgressState::Normal => config.progress_bar_normal_color,
195        ProgressState::Warning => config.progress_bar_warning_color,
196        ProgressState::Error => config.progress_bar_error_color,
197        ProgressState::Indeterminate => config.progress_bar_indeterminate_color,
198        ProgressState::Hidden => [0, 0, 0],
199    };
200    egui::Color32::from_rgba_unmultiplied(rgb[0], rgb[1], rgb[2], alpha)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_snapshot_has_active_empty() {
209        let snap = ProgressBarSnapshot {
210            simple: ProgressBar::hidden(),
211            named: HashMap::new(),
212        };
213        assert!(!snap.has_active());
214    }
215
216    #[test]
217    fn test_snapshot_has_active_simple() {
218        let snap = ProgressBarSnapshot {
219            simple: ProgressBar::normal(50),
220            named: HashMap::new(),
221        };
222        assert!(snap.has_active());
223    }
224
225    #[test]
226    fn test_snapshot_has_active_named() {
227        let mut named = HashMap::new();
228        named.insert(
229            "test".to_string(),
230            NamedProgressBar {
231                id: "test".to_string(),
232                state: ProgressState::Normal,
233                percent: 50,
234                label: Some("Testing".to_string()),
235            },
236        );
237        let snap = ProgressBarSnapshot {
238            simple: ProgressBar::hidden(),
239            named,
240        };
241        assert!(snap.has_active());
242    }
243
244    #[test]
245    fn test_state_color_normal() {
246        let config = Config::default();
247        let color = state_color(ProgressState::Normal, &config, 255);
248        assert_eq!(
249            color,
250            egui::Color32::from_rgba_unmultiplied(
251                config.progress_bar_normal_color[0],
252                config.progress_bar_normal_color[1],
253                config.progress_bar_normal_color[2],
254                255,
255            )
256        );
257    }
258
259    #[test]
260    fn test_state_color_warning() {
261        let config = Config::default();
262        let color = state_color(ProgressState::Warning, &config, 200);
263        assert_eq!(
264            color,
265            egui::Color32::from_rgba_unmultiplied(
266                config.progress_bar_warning_color[0],
267                config.progress_bar_warning_color[1],
268                config.progress_bar_warning_color[2],
269                200,
270            )
271        );
272    }
273
274    #[test]
275    fn test_state_color_error() {
276        let config = Config::default();
277        let color = state_color(ProgressState::Error, &config, 128);
278        assert_eq!(
279            color,
280            egui::Color32::from_rgba_unmultiplied(
281                config.progress_bar_error_color[0],
282                config.progress_bar_error_color[1],
283                config.progress_bar_error_color[2],
284                128,
285            )
286        );
287    }
288}