feat(progress_bar): improve determinate animations

Switches to using a critically damped spring animation, which prevents issues with rapid progress updates, and is generally smoother.
This commit is contained in:
Vukašin Vojinović 2026-06-23 18:30:28 +02:00 committed by GitHub
parent 6e116097c0
commit 76bc13ff40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 57 additions and 62 deletions

View file

@ -84,6 +84,7 @@ pub enum Message {
Hi2, Hi2,
Hi3, Hi3,
Tick, Tick,
ValueChanged(f32),
} }
/// The [`App`] stores application-specific state. /// The [`App`] stores application-specific state.
@ -95,6 +96,7 @@ pub struct App {
hidden: bool, hidden: bool,
keybinds: HashMap<KeyBind, Action>, keybinds: HashMap<KeyBind, Action>,
progress: f32, progress: f32,
progress_slider: f32,
} }
/// Implement [`cosmic::Application`] to integrate with COSMIC. /// Implement [`cosmic::Application`] to integrate with COSMIC.
@ -137,6 +139,7 @@ impl cosmic::Application for App {
hidden: true, hidden: true,
keybinds: HashMap::new(), keybinds: HashMap::new(),
progress: 0.0, progress: 0.0,
progress_slider: 0.0,
}; };
let command = app.update_title(); let command = app.update_title();
@ -185,6 +188,9 @@ impl cosmic::Application for App {
Message::Tick => { Message::Tick => {
self.progress = (self.progress + 0.01) % 1.0; self.progress = (self.progress + 0.01) % 1.0;
} }
Message::ValueChanged(value) => {
self.progress_slider = value;
}
} }
Task::none() Task::none()
} }
@ -201,7 +207,7 @@ impl cosmic::Application for App {
.map_or("No page selected", String::as_str); .map_or("No page selected", String::as_str);
let centered = widget::container( let centered = widget::container(
widget::column::with_capacity(14) widget::column::with_capacity(16)
.push(widget::text::body(page_content)) .push(widget::text::body(page_content))
.push( .push(
widget::text_input::text_input("", &self.input_1) widget::text_input::text_input("", &self.input_1)
@ -240,6 +246,16 @@ impl cosmic::Application for App {
widget::progress_bar::linear::Linear::new() widget::progress_bar::linear::Linear::new()
.girth(10.0) .girth(10.0)
.progress(self.progress) .progress(self.progress)
.width(Length::Fill),
)
.push(
widget::slider(0.0..=1.0, self.progress_slider, Message::ValueChanged)
.step(0.001),
)
.push(
widget::progress_bar::linear::Linear::new()
.girth(10.0)
.progress(self.progress_slider)
.width(Length::Fill) .width(Length::Fill)
.markers([0.25, 0.5, 0.75]) .markers([0.25, 0.5, 0.75])
.segment_spacing(2), .segment_spacing(2),

View file

@ -2,45 +2,44 @@ use crate::anim::smootherstep;
use iced::time::Instant; use iced::time::Instant;
use std::time::Duration; use std::time::Duration;
const LAG: f32 = 0.1; /// Angular frequency of the critically damped spring (snappiness)
const OMEGA: f32 = 15.0;
#[derive(Default)] #[derive(Default)]
pub struct Progress { pub struct Progress {
pub current: f32, pub current: f32,
target: Option<f32>, velocity: f32,
last: Option<Instant>, last: Option<Instant>,
} }
impl Progress { impl Progress {
/// Smoothly chases `target` using exponential decay. /// Chases `target` using a critically damped spring.
/// Returns `true` if a redraw should be requested. /// Returns `true` if a redraw should be requested.
pub fn update(&mut self, target: f32, now: Instant) -> bool { pub fn update(&mut self, target: f32, now: Instant) -> bool {
// Don't animate on start // Don't animate on start
let Some(last) = self.last else { let Some(last) = self.last else {
self.current = target; self.current = target;
self.target = Some(target); self.velocity = 0.0;
self.last = Some(now); self.last = Some(now);
return false; return false;
}; };
// Sync animation clock when target changes
if self.target != Some(target) {
self.target = Some(target);
self.last = Some(now);
return true;
}
let dt = (now - last).as_secs_f32(); let dt = (now - last).as_secs_f32();
self.last = Some(now); self.last = Some(now);
let diff = target - self.current; let displacement = self.current - target;
if diff.abs() > 0.001 { if displacement.abs() < 0.001 && self.velocity.abs() < 0.001 {
self.current += diff * (1.0 - (-dt / LAG).exp());
true
} else {
self.current = target; self.current = target;
false self.velocity = 0.0;
return false;
} }
let exp = (-OMEGA * dt).exp();
let coeff = self.velocity + OMEGA * displacement;
self.current = target + (displacement + coeff * dt) * exp;
self.velocity = (self.velocity - OMEGA * coeff * dt) * exp;
true
} }
} }

View file

@ -206,9 +206,9 @@ where
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: Rectangle { bounds: Rectangle {
x: bounds.x + x, x: bounds.x + x * bounds.width,
y: bounds.y, y: bounds.y,
width, width: width * bounds.width,
height: bounds.height, height: bounds.height,
}, },
border, border,
@ -248,7 +248,6 @@ where
let x_width = (seg_hi - seg_lo) * drawable; let x_width = (seg_hi - seg_lo) * drawable;
let segment_radius = [r_left, r_right, r_right, r_left].into(); let segment_radius = [r_left, r_right, r_right, r_left].into();
let fill = ((current_p - seg_lo) / (seg_hi - seg_lo)).min(1.0);
let border = iced::Border { let border = iced::Border {
width: border_width, width: border_width,
color: border_color, color: border_color,
@ -257,34 +256,16 @@ where
// empty segment // empty segment
if current_p < seg_lo { if current_p < seg_lo {
draw_quad( draw_quad(renderer, x_start, x_width, custom_style.track_color, border);
renderer,
x_start * bounds.width,
x_width * bounds.width,
custom_style.track_color,
border,
);
} }
// filled segment // filled segment
else if current_p > seg_hi { else if current_p > seg_hi {
draw_quad( draw_quad(renderer, x_start, x_width, custom_style.bar_color, border);
renderer,
x_start * bounds.width,
x_width * bounds.width,
custom_style.bar_color,
border,
);
} }
// partially filled segment // partially filled segment
else { else {
draw_quad( let fill = ((current_p - seg_lo) / (seg_hi - seg_lo)).min(1.0);
renderer, draw_quad(renderer, x_start, x_width, custom_style.track_color, border);
x_start * bounds.width,
x_width * bounds.width,
custom_style.track_color,
border,
);
renderer.with_layer( renderer.with_layer(
Rectangle { Rectangle {
x: bounds.x + x_start * bounds.width, x: bounds.x + x_start * bounds.width,
@ -293,13 +274,7 @@ where
height: bounds.height, height: bounds.height,
}, },
|renderer| { |renderer| {
draw_quad( draw_quad(renderer, x_start, x_width, custom_style.bar_color, border);
renderer,
x_start * bounds.width,
x_width * bounds.width,
custom_style.bar_color,
border,
);
}, },
); );
} }
@ -307,6 +282,20 @@ where
} }
// indeterminate progress bar // indeterminate progress bar
else { else {
// draw track
draw_quad(
renderer,
0.0,
1.0,
custom_style.track_color,
iced::Border {
width: border_width,
color: border_color,
radius: radius.into(),
},
);
// draw bar
let (bar_start, bar_end) = let (bar_start, bar_end) =
state state
.animation .animation
@ -316,19 +305,10 @@ where
let right_width = (1.0 - start).min(length); let right_width = (1.0 - start).min(length);
let left_width = length - right_width; let left_width = length - right_width;
let border = iced::Border { let border = iced::Border {
width: border_width,
color: border_color,
radius: radius.into(), radius: radius.into(),
..iced::Border::default()
}; };
draw_quad(
renderer,
0.0,
bounds.width,
custom_style.track_color,
border,
);
renderer.with_layer( renderer.with_layer(
Rectangle { Rectangle {
x: bounds.x, x: bounds.x,
@ -337,7 +317,7 @@ where
height: bounds.height, height: bounds.height,
}, },
|renderer| { |renderer| {
draw_quad(renderer, 0.0, bounds.width, custom_style.bar_color, border); draw_quad(renderer, 0.0, 1.0, custom_style.bar_color, border);
}, },
); );
@ -349,7 +329,7 @@ where
height: bounds.height, height: bounds.height,
}, },
|renderer| { |renderer| {
draw_quad(renderer, 0.0, bounds.width, custom_style.bar_color, border); draw_quad(renderer, 0.0, 1.0, custom_style.bar_color, border);
}, },
); );
} }