Skip to main content

iced_shadcn/
progress.rs

1use iced::advanced::layout;
2use iced::advanced::renderer;
3use iced::advanced::widget::Tree;
4use iced::advanced::{Clipboard, Layout, Shell, Widget};
5use iced::border::Border;
6use iced::window;
7use iced::{Background, Color, Element, Event, Length, Rectangle, Shadow, Size};
8
9use crate::button::ButtonRadius;
10use crate::theme::Theme;
11use crate::tokens::{AccentColor, accent_color, accent_high, accent_low, is_dark};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
14pub enum ProgressSize {
15    Size1,
16    #[default]
17    Size2,
18    Size3,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
22pub enum ProgressVariant {
23    Classic,
24    #[default]
25    Surface,
26    Soft,
27}
28
29#[derive(Clone, Copy, Debug)]
30pub struct ProgressProps {
31    pub size: ProgressSize,
32    pub variant: ProgressVariant,
33    pub color: AccentColor,
34    pub radius: Option<ButtonRadius>,
35    pub high_contrast: bool,
36    pub duration_ms: u32,
37    pub value: Option<f32>,
38    pub max: f32,
39}
40
41impl Default for ProgressProps {
42    fn default() -> Self {
43        Self {
44            size: ProgressSize::Size2,
45            variant: ProgressVariant::Surface,
46            color: AccentColor::Gray,
47            radius: None,
48            high_contrast: false,
49            duration_ms: 1200,
50            value: None,
51            max: 100.0,
52        }
53    }
54}
55
56impl ProgressProps {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    pub fn size(mut self, size: ProgressSize) -> Self {
62        self.size = size;
63        self
64    }
65
66    pub fn variant(mut self, variant: ProgressVariant) -> Self {
67        self.variant = variant;
68        self
69    }
70
71    pub fn color(mut self, color: AccentColor) -> Self {
72        self.color = color;
73        self
74    }
75
76    pub fn radius(mut self, radius: ButtonRadius) -> Self {
77        self.radius = Some(radius);
78        self
79    }
80
81    pub fn high_contrast(mut self, high_contrast: bool) -> Self {
82        self.high_contrast = high_contrast;
83        self
84    }
85
86    pub fn duration_ms(mut self, duration_ms: u32) -> Self {
87        self.duration_ms = duration_ms.max(1);
88        self
89    }
90
91    pub fn value(mut self, value: f32) -> Self {
92        self.value = Some(value);
93        self
94    }
95
96    pub fn indeterminate(mut self) -> Self {
97        self.value = None;
98        self
99    }
100
101    pub fn max(mut self, max: f32) -> Self {
102        self.max = max.max(1.0);
103        self
104    }
105}
106
107fn apply_opacity(color: Color, opacity: f32) -> Color {
108    Color {
109        a: color.a * opacity,
110        ..color
111    }
112}
113
114impl ProgressSize {
115    fn height(self) -> f32 {
116        match self {
117            ProgressSize::Size1 => 4.0,
118            ProgressSize::Size2 => 8.0,
119            ProgressSize::Size3 => 12.0,
120        }
121    }
122}
123
124fn progress_radius(theme: &Theme, props: ProgressProps) -> f32 {
125    let height = props.size.height();
126    match props.radius {
127        Some(ButtonRadius::None) => 0.0,
128        Some(ButtonRadius::Small) => theme.radius.sm,
129        Some(ButtonRadius::Medium) => theme.radius.md,
130        Some(ButtonRadius::Large) => theme.radius.lg,
131        Some(ButtonRadius::Full) => (height / 2.0).max(9999.0),
132        None => (height / 3.0).max(theme.radius.sm),
133    }
134}
135
136#[derive(Debug, Default)]
137struct ProgressState {
138    start_time: Option<std::time::Instant>,
139    phase: f32,
140}
141
142pub fn progress(props: ProgressProps, theme: &Theme) -> ProgressWidget {
143    ProgressWidget::new(props, theme)
144}
145
146pub struct ProgressWidget {
147    props: ProgressProps,
148    theme: Theme,
149}
150
151impl ProgressWidget {
152    fn new(props: ProgressProps, theme: &Theme) -> Self {
153        Self {
154            props,
155            theme: theme.clone(),
156        }
157    }
158}
159
160impl<Message, AppTheme, Renderer> Widget<Message, AppTheme, Renderer> for ProgressWidget
161where
162    Renderer: renderer::Renderer,
163{
164    fn tag(&self) -> iced::advanced::widget::tree::Tag {
165        iced::advanced::widget::tree::Tag::of::<ProgressState>()
166    }
167
168    fn state(&self) -> iced::advanced::widget::tree::State {
169        iced::advanced::widget::tree::State::new(ProgressState::default())
170    }
171
172    fn size(&self) -> Size<Length> {
173        Size::new(Length::Fill, Length::Fixed(self.props.size.height()))
174    }
175
176    fn layout(
177        &mut self,
178        _tree: &mut Tree,
179        _renderer: &Renderer,
180        limits: &layout::Limits,
181    ) -> layout::Node {
182        layout::atomic(
183            limits,
184            Length::Fill,
185            Length::Fixed(self.props.size.height()),
186        )
187    }
188
189    fn update(
190        &mut self,
191        tree: &mut Tree,
192        event: &Event,
193        _layout: Layout<'_>,
194        _cursor: iced::mouse::Cursor,
195        _renderer: &Renderer,
196        _clipboard: &mut dyn Clipboard,
197        shell: &mut Shell<'_, Message>,
198        _viewport: &Rectangle,
199    ) {
200        if self.props.value.is_some() {
201            return;
202        }
203
204        if let Event::Window(window::Event::RedrawRequested(now)) = event {
205            let state = tree.state.downcast_mut::<ProgressState>();
206
207            if state.start_time.is_none() {
208                state.start_time = Some(*now);
209            }
210
211            if let Some(start) = state.start_time {
212                let elapsed = now.saturating_duration_since(start);
213                let duration = std::time::Duration::from_millis(self.props.duration_ms as u64);
214                state.phase = (elapsed.as_secs_f32() / duration.as_secs_f32()) % 1.0;
215            }
216
217            shell.request_redraw();
218        }
219    }
220
221    fn draw(
222        &self,
223        tree: &Tree,
224        renderer: &mut Renderer,
225        _theme: &AppTheme,
226        _style: &renderer::Style,
227        layout: Layout<'_>,
228        _cursor: iced::mouse::Cursor,
229        viewport: &Rectangle,
230    ) {
231        let bounds = layout.bounds();
232        if !bounds.intersects(viewport) {
233            return;
234        }
235
236        let palette = self.theme.palette;
237        let radius = progress_radius(&self.theme, self.props);
238
239        let base_bg = if is_dark(&palette) {
240            apply_opacity(accent_low(&palette, AccentColor::Gray), 0.9)
241        } else {
242            apply_opacity(accent_low(&palette, AccentColor::Gray), 0.7)
243        };
244
245        let (background, border, shadow) = match self.props.variant {
246            ProgressVariant::Surface => (
247                Background::Color(base_bg),
248                Border {
249                    color: apply_opacity(palette.border, 0.7),
250                    width: 1.0,
251                    radius: radius.into(),
252                },
253                Shadow::default(),
254            ),
255            ProgressVariant::Classic => (
256                Background::Color(base_bg),
257                Border {
258                    color: Color::TRANSPARENT,
259                    width: 0.0,
260                    radius: radius.into(),
261                },
262                Shadow {
263                    color: apply_opacity(Color::BLACK, 0.10),
264                    offset: iced::Vector::new(0.0, 1.0),
265                    blur_radius: 8.0,
266                },
267            ),
268            ProgressVariant::Soft => (
269                Background::Color(apply_opacity(accent_low(&palette, AccentColor::Gray), 1.0)),
270                Border {
271                    color: Color::TRANSPARENT,
272                    width: 0.0,
273                    radius: radius.into(),
274                },
275                Shadow::default(),
276            ),
277        };
278
279        renderer.fill_quad(
280            renderer::Quad {
281                bounds,
282                border,
283                shadow,
284                ..renderer::Quad::default()
285            },
286            background,
287        );
288
289        let indicator_color = if self.props.high_contrast {
290            accent_high(&palette, self.props.color)
291        } else {
292            accent_color(&palette, self.props.color)
293        };
294
295        let (indicator_x, indicator_width) = if let Some(value) = self.props.value {
296            let ratio = if self.props.max <= 0.0 {
297                0.0
298            } else {
299                (value / self.props.max).clamp(0.0, 1.0)
300            };
301            (bounds.x, bounds.width * ratio)
302        } else {
303            // Smooth back-and-forth using sin (like egui)
304            let state = tree.state.downcast_ref::<ProgressState>();
305            let bar_width = (bounds.width * 0.35).max(12.0);
306            let travel = bounds.width - bar_width;
307            let t = (state.phase * std::f32::consts::PI * 2.0).sin() * 0.5 + 0.5;
308            let x = bounds.x + travel * t;
309            (x, bar_width)
310        };
311
312        if indicator_width <= 0.0 {
313            return;
314        }
315
316        let indicator_bounds = Rectangle {
317            x: indicator_x,
318            y: bounds.y,
319            width: indicator_width,
320            height: bounds.height,
321        };
322
323        renderer.fill_quad(
324            renderer::Quad {
325                bounds: indicator_bounds,
326                border: Border {
327                    color: Color::TRANSPARENT,
328                    width: 0.0,
329                    radius: radius.into(),
330                },
331                ..renderer::Quad::default()
332            },
333            Background::Color(indicator_color),
334        );
335    }
336}
337
338impl<'a, Message, AppTheme, Renderer> From<ProgressWidget>
339    for Element<'a, Message, AppTheme, Renderer>
340where
341    Renderer: renderer::Renderer + 'a,
342    Message: 'a,
343{
344    fn from(widget: ProgressWidget) -> Element<'a, Message, AppTheme, Renderer> {
345        Element::new(widget)
346    }
347}