Skip to main content

ccf_gpui_widgets/widgets/
progress_bar.rs

1//! Progress bar widget
2//!
3//! A progress bar for showing task completion status.
4//! Supports determinate (known progress) and indeterminate (unknown progress) modes.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::ProgressBar;
10//!
11//! // Determinate progress (known percentage)
12//! let progress = cx.new(|_cx| {
13//!     ProgressBar::new()
14//!         .with_value(0.5)  // 50%
15//!         .show_percentage(true)
16//! });
17//!
18//! // Indeterminate progress (unknown duration)
19//! let loading = cx.new(|_cx| {
20//!     ProgressBar::new()
21//!         .indeterminate()
22//!         .label("Loading...")
23//! });
24//!
25//! // Subscribe to completion
26//! cx.subscribe(&progress, |this, _progress, event: &ProgressBarEvent, cx| {
27//!     if let ProgressBarEvent::Complete = event {
28//!         println!("Progress complete!");
29//!     }
30//! }).detach();
31//! ```
32
33use std::time::Duration;
34
35use gpui::prelude::*;
36use gpui::*;
37
38use crate::theme::{get_theme_or, Theme};
39
40/// Events emitted by ProgressBar
41#[derive(Clone, Debug)]
42pub enum ProgressBarEvent {
43    /// Progress reached 100%
44    Complete,
45}
46
47/// Progress bar widget
48pub struct ProgressBar {
49    /// Current value (0.0 to max, None for indeterminate)
50    value: Option<f64>,
51    min: f64,
52    max: f64,
53    custom_theme: Option<Theme>,
54    show_percentage: bool,
55    label: Option<SharedString>,
56    /// Whether Complete event has been emitted
57    completed_emitted: bool,
58}
59
60impl EventEmitter<ProgressBarEvent> for ProgressBar {}
61
62impl ProgressBar {
63    /// Create a new progress bar (determinate mode, starting at 0)
64    pub fn new() -> Self {
65        Self {
66            value: Some(0.0),
67            min: 0.0,
68            max: 1.0,
69            custom_theme: None,
70            show_percentage: false,
71            label: None,
72            completed_emitted: false,
73        }
74    }
75
76    /// Set initial value (builder pattern)
77    #[must_use]
78    pub fn with_value(mut self, value: f64) -> Self {
79        self.value = Some(value.clamp(self.min, self.max));
80        self
81    }
82
83    /// Set minimum value (builder pattern)
84    #[must_use]
85    pub fn min(mut self, min: f64) -> Self {
86        self.min = min;
87        if let Some(v) = self.value {
88            self.value = Some(v.clamp(self.min, self.max));
89        }
90        self
91    }
92
93    /// Set maximum value (builder pattern)
94    #[must_use]
95    pub fn max(mut self, max: f64) -> Self {
96        self.max = max;
97        if let Some(v) = self.value {
98            self.value = Some(v.clamp(self.min, self.max));
99        }
100        self
101    }
102
103    /// Set to indeterminate mode (builder pattern)
104    #[must_use]
105    pub fn indeterminate(mut self) -> Self {
106        self.value = None;
107        self
108    }
109
110    /// Show percentage text (builder pattern)
111    #[must_use]
112    pub fn show_percentage(mut self, show: bool) -> Self {
113        self.show_percentage = show;
114        self
115    }
116
117    /// Set label text (builder pattern)
118    #[must_use]
119    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
120        self.label = Some(text.into());
121        self
122    }
123
124    /// Set custom theme (builder pattern)
125    #[must_use]
126    pub fn theme(mut self, theme: Theme) -> Self {
127        self.custom_theme = Some(theme);
128        self
129    }
130
131    /// Get the current value (None if indeterminate)
132    pub fn value(&self) -> Option<f64> {
133        self.value
134    }
135
136    /// Get the current percentage (0.0-1.0, None if indeterminate)
137    pub fn percentage(&self) -> Option<f64> {
138        self.value.map(|v| {
139            if (self.max - self.min).abs() < f64::EPSILON {
140                0.0
141            } else {
142                (v - self.min) / (self.max - self.min)
143            }
144        })
145    }
146
147    /// Check if progress is complete
148    pub fn is_complete(&self) -> bool {
149        self.value.is_some_and(|v| (v - self.max).abs() < f64::EPSILON)
150    }
151
152    /// Check if in indeterminate mode
153    pub fn is_indeterminate(&self) -> bool {
154        self.value.is_none()
155    }
156
157    /// Set value programmatically
158    pub fn set_value(&mut self, value: f64, cx: &mut Context<Self>) {
159        let clamped = value.clamp(self.min, self.max);
160        let old_value = self.value;
161        self.value = Some(clamped);
162
163        // Emit Complete event when reaching max (only once)
164        if !self.completed_emitted && (clamped - self.max).abs() < f64::EPSILON {
165            // Only emit if we weren't already at max
166            if old_value.is_none_or(|v| (v - self.max).abs() >= f64::EPSILON) {
167                self.completed_emitted = true;
168                cx.emit(ProgressBarEvent::Complete);
169            }
170        }
171
172        cx.notify();
173    }
174
175    /// Set to indeterminate mode programmatically
176    pub fn set_indeterminate(&mut self, cx: &mut Context<Self>) {
177        self.value = None;
178        self.completed_emitted = false;
179        cx.notify();
180    }
181
182    /// Reset progress to 0
183    pub fn reset(&mut self, cx: &mut Context<Self>) {
184        self.value = Some(self.min);
185        self.completed_emitted = false;
186        cx.notify();
187    }
188}
189
190impl Default for ProgressBar {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl Render for ProgressBar {
197    fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
198        let theme = get_theme_or(cx, self.custom_theme.as_ref());
199        let percentage = self.percentage();
200        let show_percentage = self.show_percentage;
201        let label = self.label.clone();
202        let is_indeterminate = self.is_indeterminate();
203
204        // Dimensions
205        let bar_height = 8.0;
206
207        // Calculate display percentage string
208        let percentage_text = percentage.map(|p| format!("{:.0}%", p * 100.0));
209
210        // Build the track element
211        let track_element = if is_indeterminate {
212            // Indeterminate: animated pulsing bar
213            div()
214                .relative()
215                .flex_1()
216                .h(px(bar_height))
217                .rounded_full()
218                .bg(rgb(theme.bg_input))
219                .overflow_hidden()
220                .child(
221                    div()
222                        .absolute()
223                        .top_0()
224                        .bottom_0()
225                        .w(relative(0.3))
226                        .rounded_full()
227                        .bg(rgb(theme.primary))
228                        .with_animation(
229                            "indeterminate_slide",
230                            Animation::new(Duration::from_millis(1500))
231                                .repeat(),
232                            move |el, delta| {
233                                // Move from -30% to 100%
234                                let position = -0.3 + delta * 1.3;
235                                el.left(relative(position))
236                            },
237                        )
238                )
239        } else {
240            // Determinate: filled bar based on percentage
241            let fill_width = percentage.unwrap_or(0.0) as f32;
242
243            div()
244                .relative()
245                .flex_1()
246                .h(px(bar_height))
247                .rounded_full()
248                .bg(rgb(theme.bg_input))
249                .overflow_hidden()
250                .child(
251                    div()
252                        .h_full()
253                        .w(relative(fill_width))
254                        .rounded_full()
255                        .bg(rgb(theme.primary))
256                )
257        };
258
259        div()
260            .id("ccf_progress_bar")
261            .flex()
262            .flex_col()
263            .gap_1()
264            .w_full()
265            // Label row (if present)
266            .when_some(label.clone(), |d, text| {
267                d.child(
268                    div()
269                        .flex()
270                        .flex_row()
271                        .justify_between()
272                        .child(
273                            div()
274                                .text_sm()
275                                .text_color(rgb(theme.text_label))
276                                .child(text)
277                        )
278                        .when(show_percentage && percentage_text.is_some(), |d| {
279                            d.child(
280                                div()
281                                    .text_sm()
282                                    .text_color(rgb(theme.text_muted))
283                                    .child(percentage_text.clone().unwrap_or_default())
284                            )
285                        })
286                )
287            })
288            // Track
289            .child(track_element)
290            // Percentage below (if no label)
291            .when(show_percentage && label.is_none() && percentage_text.is_some(), |d| {
292                d.child(
293                    div()
294                        .text_sm()
295                        .text_color(rgb(theme.text_muted))
296                        .text_right()
297                        .child(percentage_text.unwrap_or_default())
298                )
299            })
300    }
301}
302