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