use crate::event::Rect;
use crate::types::{Color, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProgressBar {
pub id: WidgetId,
#[serde(default)]
pub label: String,
#[serde(default)]
pub value: Option<f32>,
#[serde(default)]
pub frame_idx: usize,
#[serde(default)]
pub cancellable: bool,
#[serde(default)]
pub accent: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ProgressBarMeasure {
pub width: f32,
pub height: f32,
pub cancel_width: f32,
}
impl ProgressBarMeasure {
pub fn new(width: f32, height: f32) -> Self {
Self {
width,
height,
cancel_width: 0.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProgressBarHit {
Cancel(WidgetId),
Body(WidgetId),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProgressBarLayout {
pub bounds: Rect,
pub fill_bounds: Option<Rect>,
pub cancel_bounds: Option<Rect>,
pub hit_regions: Vec<(Rect, ProgressBarHit)>,
}
impl ProgressBarLayout {
pub fn hit_test(&self, x: f32, y: f32) -> ProgressBarHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
ProgressBarHit::Empty
}
}
impl ProgressBar {
pub fn layout(
&self,
origin_x: f32,
origin_y: f32,
measure: ProgressBarMeasure,
) -> ProgressBarLayout {
let bounds = Rect::new(origin_x, origin_y, measure.width, measure.height);
let cancel_width = if self.cancellable {
measure.cancel_width
} else {
0.0
};
let bar_width = (measure.width - cancel_width).max(0.0);
let fill_bounds = if let Some(frac) = self.value {
let f = frac.clamp(0.0, 1.0);
Some(Rect::new(origin_x, origin_y, bar_width * f, measure.height))
} else {
None
};
let cancel_bounds = if self.cancellable && cancel_width > 0.0 {
Some(Rect::new(
origin_x + bar_width,
origin_y,
cancel_width,
measure.height,
))
} else {
None
};
let mut hit_regions: Vec<(Rect, ProgressBarHit)> = Vec::new();
if let Some(cb) = cancel_bounds {
hit_regions.push((cb, ProgressBarHit::Cancel(self.id)));
}
hit_regions.push((bounds, ProgressBarHit::Body(self.id)));
ProgressBarLayout {
bounds,
fill_bounds,
cancel_bounds,
hit_regions,
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{ProgressBar, ProgressBarLayout};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::primitives::layout_metrics::{pixel, pixel_progress_layout};
use crate::style::Style;
use crate::theme::Theme;
pub(crate) fn paint(
bar: &ProgressBar,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
style: &Style,
) -> ProgressBarLayout {
let layout = pixel_progress_layout(bar, rect.x, rect.y, rect.width, rect.height);
surface.surface_fill_rounded_rect(rect, style.corner_radius, theme.surface_bg);
let fill_color = bar.accent.unwrap_or(theme.accent_bg);
if let Some(fb) = layout.fill_bounds {
surface.surface_fill_rounded_rect(fb, style.corner_radius, fill_color);
} else {
let bar_w = if bar.cancellable {
(rect.width - pixel::PROGRESS_CANCEL_WIDTH).max(0.0)
} else {
rect.width
};
if bar_w > 0.0 {
let pulse_w = pixel::PROGRESS_PULSE_WIDTH.min(bar_w);
let pos = (bar.frame_idx as f32 * 4.0) % bar_w;
let w = pulse_w.min(bar_w - pos);
if w > 0.0 {
surface.surface_fill_rounded_rect(
Rect::new(rect.x + pos, rect.y, w, rect.height),
style.corner_radius,
fill_color,
);
}
}
}
if !bar.label.is_empty() {
surface.surface_draw_text_run(
Rect::new(
rect.x + 4.0,
rect.y,
(rect.width - 4.0).max(0.0),
rect.height,
),
&bar.label,
theme.foreground,
);
}
if let Some(cb) = layout.cancel_bounds {
let (tw, _) = surface.surface_measure_text("\u{d7}");
let glyph_x = cb.x + ((cb.width - tw) / 2.0).max(0.0);
surface.surface_draw_text_run(
Rect::new(
glyph_x,
cb.y,
(cb.x + cb.width - glyph_x).max(1.0),
cb.height,
),
"\u{d7}",
theme.foreground,
);
}
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 20.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn bar(value: Option<f32>, cancellable: bool) -> ProgressBar {
ProgressBar {
id: WidgetId::new("p"),
label: String::new(),
value,
frame_idx: 0,
cancellable,
accent: None,
}
}
#[test]
fn determinate_fill_uses_the_caller_supplied_theme() {
let bar = bar(Some(0.5), false);
let custom_accent = Color::rgb(12, 200, 250);
let theme = Theme {
accent_bg: custom_accent,
..Theme::default()
};
let mut surface = RecordingSurface::default();
let layout = paint(
&bar,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 200.0, 20.0),
&Style::default(),
);
let fb = layout.fill_bounds.expect("determinate fill present");
assert!(surface
.fills
.iter()
.any(|(r, c)| *r == fb && *c == custom_accent));
}
#[test]
fn label_and_cancel_glyph_are_drawn_as_text_runs() {
let mut bar = bar(Some(0.5), true);
bar.label = "Uploading".into();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&bar,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 200.0, 20.0),
&Style::default(),
);
assert!(surface.text_runs.iter().any(|(_, t, _)| t == "Uploading"));
assert!(surface.text_runs.iter().any(|(_, t, _)| t == "\u{d7}"));
}
#[test]
fn indeterminate_pulse_moves_with_frame_idx() {
let theme = Theme::default();
let mut bar0 = bar(None, false);
bar0.frame_idx = 0;
let mut surface0 = RecordingSurface::default();
paint(
&bar0,
&mut surface0,
&theme,
Rect::new(0.0, 0.0, 200.0, 20.0),
&Style::default(),
);
let mut bar1 = bar(None, false);
bar1.frame_idx = 5;
let mut surface1 = RecordingSurface::default();
paint(
&bar1,
&mut surface1,
&theme,
Rect::new(0.0, 0.0, 200.0, 20.0),
&Style::default(),
);
let pulse0 = surface0.fills.last().expect("pulse fill").0;
let pulse1 = surface1.fills.last().expect("pulse fill").0;
assert_ne!(pulse0.x, pulse1.x);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_bar_layout_determinate() {
let p = ProgressBar {
id: WidgetId::new("download"),
label: "Downloading…".to_string(),
value: Some(0.4),
frame_idx: 0,
cancellable: false,
accent: None,
};
let layout = p.layout(0.0, 0.0, ProgressBarMeasure::new(200.0, 8.0));
assert_eq!(layout.bounds.width, 200.0);
let fill = layout.fill_bounds.unwrap();
assert_eq!(fill.width, 80.0); assert!(layout.cancel_bounds.is_none());
}
#[test]
fn progress_bar_layout_indeterminate() {
let p = ProgressBar {
id: WidgetId::new("op"),
label: String::new(),
value: None,
frame_idx: 5,
cancellable: false,
accent: None,
};
let layout = p.layout(0.0, 0.0, ProgressBarMeasure::new(100.0, 4.0));
assert!(layout.fill_bounds.is_none());
}
#[test]
fn progress_bar_layout_cancellable() {
let p = ProgressBar {
id: WidgetId::new("install"),
label: "Installing…".to_string(),
value: Some(0.5),
frame_idx: 0,
cancellable: true,
accent: None,
};
let layout = p.layout(
0.0,
0.0,
ProgressBarMeasure {
width: 200.0,
height: 8.0,
cancel_width: 20.0,
},
);
let fill = layout.fill_bounds.unwrap();
assert_eq!(fill.width, (200.0 - 20.0) * 0.5); let cancel = layout.cancel_bounds.unwrap();
assert_eq!(cancel.x, 180.0);
assert_eq!(cancel.width, 20.0);
match layout.hit_test(190.0, 4.0) {
ProgressBarHit::Cancel(id) => assert_eq!(id.as_str(), "install"),
_ => panic!("expected Cancel hit"),
}
match layout.hit_test(50.0, 4.0) {
ProgressBarHit::Body(id) => assert_eq!(id.as_str(), "install"),
_ => panic!("expected Body hit"),
}
}
#[test]
fn progress_bar_value_clamped() {
let p = ProgressBar {
id: WidgetId::new("overrun"),
label: String::new(),
value: Some(1.5), frame_idx: 0,
cancellable: false,
accent: None,
};
let layout = p.layout(0.0, 0.0, ProgressBarMeasure::new(100.0, 4.0));
assert_eq!(layout.fill_bounds.unwrap().width, 100.0);
}
}