From c4151d649c157c04a7c7cd9c64cc196ca3c15984 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Sun, 10 Dec 2023 15:05:59 -0800 Subject: [PATCH] Added Spinner widget Closes #80 --- Cargo.lock | 1 + Cargo.toml | 1 + examples/progress.rs | 9 +- src/animation.rs | 63 +++++++--- src/widgets/data.rs | 10 +- src/widgets/progress.rs | 249 ++++++++++++++++++++++++++++++++++------ 6 files changed, 281 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f7ec83..514a93c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,6 +810,7 @@ dependencies = [ "interner", "kempt", "kludgine", + "lyon_geom", "palette", "pollster", "rand", diff --git a/Cargo.toml b/Cargo.toml index 7f40e3e..97d023c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ gooey-macros = { version = "0.1.0", path = "gooey-macros" } arboard = "3.2.1" zeroize = "1.6.1" unicode-segmentation = "1.10.1" +lyon_geom = "1.0.4" # [patch."https://github.com/khonsulabs/kludgine"] diff --git a/examples/progress.rs b/examples/progress.rs index 5d81579..4770fbe 100644 --- a/examples/progress.rs +++ b/examples/progress.rs @@ -15,7 +15,14 @@ fn main() -> gooey::Result { value .clone() .slider() - .and(progress.clone().progress_bar()) + .and( + progress + .clone() + .progress_bar() + .expand() + .and(progress.clone().progress_bar().spinner()) + .into_columns(), + ) .and("Indeterminant".into_checkbox(indeterminant)) .into_rows() .fit_horizontally() diff --git a/src/animation.rs b/src/animation.rs index ad47e9c..0558b64 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -308,6 +308,30 @@ pub trait AnimateTarget: Send + Sync { fn finish(&self); } +macro_rules! impl_tuple_animate { + ($($type:ident $field:tt $var:ident),+) => { + impl<$($type),+> AnimationTarget for ($($type,)+) where $($type: AnimationTarget),+ { + type Running = ($(<$type>::Running,)+); + + fn begin(self) -> Self::Running { + ($(self.$field.begin(),)+) + } + } + + impl<$($type),+> AnimateTarget for ($($type,)+) where $($type: AnimateTarget),+ { + fn update(&self, percent: f32) { + $(self.$field.update(percent);)+ + } + + fn finish(&self) { + $(self.$field.finish();)+ + } + } + } +} + +impl_all_tuples!(impl_tuple_animate); + /// A type that can convert into `Box`. pub trait BoxAnimate { /// Returns the boxed animation. @@ -353,29 +377,40 @@ pub trait IntoAnimate: Sized + Send + Sync { } } -macro_rules! impl_tuple_animate { +macro_rules! impl_tuple_into_animate { ($($type:ident $field:tt $var:ident),+) => { - impl<$($type),+> AnimationTarget for ($($type,)+) where $($type: AnimationTarget),+ { - type Running = ($(<$type>::Running,)+); + impl<$($type),+> IntoAnimate for ($($type,)+) where $($type: IntoAnimate),+ { + type Animate = ($(<$type>::Animate,)+); - fn begin(self) -> Self::Running { - ($(self.$field.begin(),)+) + fn into_animate(self) -> Self::Animate { + ($(self.$field.into_animate(),)+) } } - - impl<$($type),+> AnimateTarget for ($($type,)+) where $($type: AnimateTarget),+ { - fn update(&self, percent: f32) { - $(self.$field.update(percent);)+ - } - - fn finish(&self) { - $(self.$field.finish();)+ + impl<$($type),+> Animate for ($($type,)+) where $($type: Animate),+ { + fn animate(&mut self, elapsed: Duration) -> ControlFlow { + let mut min_remaining = Duration::MAX; + let mut completely_done = true; + $( + match self.$field.animate(elapsed) { + ControlFlow::Break(remaining) => { + min_remaining = min_remaining.min(remaining); + } + ControlFlow::Continue(()) => { + completely_done = false; + } + } + )+ + if completely_done { + ControlFlow::Break(min_remaining) + } else { + ControlFlow::Continue(()) + } } } } } -impl_all_tuples!(impl_tuple_animate); +impl_all_tuples!(impl_tuple_into_animate); impl BoxAnimate for T where diff --git a/src/widgets/data.rs b/src/widgets/data.rs index f7fa1d0..100ff07 100644 --- a/src/widgets/data.rs +++ b/src/widgets/data.rs @@ -19,7 +19,10 @@ pub struct Data { child: WidgetRef, } -impl Data { +impl Data +where + T: Debug, +{ /// Returns an empty widget with the contained value. pub fn new(value: T) -> Self { Self::new_wrapping(value, Space::clear()) @@ -34,7 +37,10 @@ impl Data { } } -impl From for Data { +impl From for Data +where + T: Debug, +{ fn from(value: T) -> Self { Self::new(value) } diff --git a/src/widgets/progress.rs b/src/widgets/progress.rs index 33d314e..eb41cdf 100644 --- a/src/widgets/progress.rs +++ b/src/widgets/progress.rs @@ -3,21 +3,26 @@ use std::ops::RangeInclusive; use std::time::Duration; -use kludgine::figures::{Ranged, Zero}; +use kludgine::figures::units::Px; +use kludgine::figures::{FloatConversion, Point, Ranged, ScreenScale, Zero}; +use kludgine::shapes::{Path, PathEvent, StrokeOptions}; +use kludgine::Color; +use lyon_geom::Arc; -use crate::animation::easings::{EaseInOutQuadradic, EaseOutQuadradic}; +use crate::animation::easings::{EaseInQuadradic, EaseOutQuadradic}; use crate::animation::{ AnimationHandle, AnimationTarget, IntoAnimate, PercentBetween, Spawn, ZeroToOne, }; use crate::value::{Dynamic, IntoDynamic, IntoValue, MapEach, Value}; -use crate::widget::{MakeWidget, MakeWidgetWithId, WidgetInstance}; -use crate::widgets::slider::Slidable; +use crate::widget::{MakeWidget, MakeWidgetWithId, Widget, WidgetInstance}; +use crate::widgets::slider::{InactiveTrackColor, Slidable, TrackColor, TrackSize}; use crate::widgets::Data; /// A bar-shaped progress indicator. #[derive(Debug)] pub struct ProgressBar { progress: Value, + spinner: bool, } impl ProgressBar { @@ -26,6 +31,7 @@ impl ProgressBar { pub const fn indeterminant() -> Self { Self { progress: Value::Constant(Progress::Indeterminant), + spinner: false, } } @@ -34,8 +40,16 @@ impl ProgressBar { pub fn new(progress: impl IntoDynamic) -> Self { Self { progress: Value::Dynamic(progress.into_dynamic()), + spinner: false, } } + + /// Returns a new progress bar that displays `progress`. + #[must_use] + pub fn spinner(mut self) -> Self { + self.spinner = true; + self + } } /// A measurement of progress for an indicator widget like [`ProgressBar`]. @@ -54,18 +68,43 @@ impl MakeWidgetWithId for ProgressBar { let value = (&start, &end).map_each(|(start, end)| *start..=*end); let mut indeterminant_animation = None; + + let (slider, degree_offset) = if self.spinner { + let degree_offset = Dynamic::new(0.); + ( + Spinner { + start: start.clone(), + end: end.clone(), + degree_offset: degree_offset.clone(), + } + .make_with_id(id), + Some(degree_offset), + ) + } else { + ( + value.slider().knobless().non_interactive().make_with_id(id), + None, + ) + }; + update_progress_bar( self.progress.get(), &mut indeterminant_animation, &start, &end, + degree_offset.as_ref(), ); - let slider = value.slider().knobless().non_interactive().make_with_id(id); match self.progress { Value::Dynamic(progress) => { let callback = progress.for_each(move |progress| { - update_progress_bar(*progress, &mut indeterminant_animation, &start, &end); + update_progress_bar( + *progress, + &mut indeterminant_animation, + &start, + &end, + degree_offset.as_ref(), + ); }); Data::new_wrapping((callback, progress), slider).make_widget() } @@ -74,50 +113,75 @@ impl MakeWidgetWithId for ProgressBar { } } +#[derive(Debug)] +struct IndeterminantAnimations { + _primary: AnimationHandle, + _degree_offset: Option, +} + fn update_progress_bar( progress: Progress, - indeterminant_animation: &mut Option, + indeterminant_animation: &mut Option, start: &Dynamic, end: &Dynamic, + degree_offset: Option<&Dynamic>, ) { match progress { Progress::Indeterminant => { if indeterminant_animation.is_none() { - *indeterminant_animation = Some( - ( - start.transition_to(ZeroToOne::ZERO), - end.transition_to(ZeroToOne::ZERO), + *indeterminant_animation = Some(IndeterminantAnimations { + _primary: ( + start + .transition_to(ZeroToOne::ZERO) + .immediately() + .and_then(Duration::from_millis(250)) + .and_then( + start + .transition_to(ZeroToOne::new(0.33)) + .over(Duration::from_millis(500)) + .with_easing(EaseInQuadradic), + ) + .and_then( + start + .transition_to(ZeroToOne::new(1.0)) + .over(Duration::from_millis(500)) + .with_easing(EaseOutQuadradic), + ), + end.transition_to(ZeroToOne::ZERO) + .immediately() + .and_then( + end.transition_to(ZeroToOne::new(0.75)) + .over(Duration::from_millis(500)) + .with_easing(EaseInQuadradic), + ) + .and_then( + end.transition_to(ZeroToOne::ONE) + .over(Duration::from_millis(250)) + .with_easing(EaseOutQuadradic), + ), ) - .immediately() - .and_then( - end.transition_to(ZeroToOne::new(0.75)) - .over(Duration::from_millis(500)) - .with_easing(EaseOutQuadradic), - ) - .and_then( - start - .transition_to(ZeroToOne::new(0.25)) - .over(Duration::from_millis(500)) - .with_easing(EaseOutQuadradic), - ) - .and_then( - end.transition_to(ZeroToOne::ONE) - .over(Duration::from_millis(250)) - .with_easing(EaseOutQuadradic), - ) - .and_then( - start - .transition_to(ZeroToOne::ONE) - .over(Duration::from_millis(250)) - .with_easing(EaseInOutQuadradic), - ) .cycle() .spawn(), - ); + _degree_offset: degree_offset.map(|degree_offset| { + degree_offset + .transition_to(0.) + .immediately() + .and_then( + degree_offset + .transition_to(359.9) + .over(Duration::from_secs_f32(1.66)), + ) + .cycle() + .spawn() + }), + }); } } Progress::Percent(value) => { let _stopped_animation = indeterminant_animation.take(); + if let Some(degree_offset) = degree_offset { + degree_offset.set(0.); + } start.set(ZeroToOne::ZERO); end.set(value); } @@ -233,3 +297,118 @@ where } } } + +/// A circular progress widget. +#[derive(Debug)] +pub struct Spinner { + start: Dynamic, + end: Dynamic, + degree_offset: Dynamic, +} + +impl Spinner { + fn draw_arc( + track_size: Px, + radius: f32, + degree_offset: f32, + start: ZeroToOne, + sweep: ZeroToOne, + color: Color, + context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>, + ) { + let mut events = Vec::>::new(); + Arc { + center: lyon_geom::point( + radius + track_size.into_float() / 2., + radius + track_size.into_float() / 2., + ), + radii: lyon_geom::vector(radius, radius), + start_angle: lyon_geom::Angle::degrees(*start * 360. - 90. + degree_offset), + sweep_angle: lyon_geom::Angle::degrees(*sweep * 360.), + x_rotation: lyon_geom::Angle::zero(), + } + .for_each_cubic_bezier(&mut |segment| { + if events.is_empty() { + events.push(PathEvent::Begin { + at: Point::new(segment.from.x, segment.from.y).cast(), + texture: Point::ZERO, + }); + } + events.push(PathEvent::Cubic { + ctrl1: Point::new(segment.ctrl1.x, segment.ctrl1.y).cast(), + ctrl2: Point::new(segment.ctrl2.x, segment.ctrl2.y).cast(), + to: Point::new(segment.to.x, segment.to.y).cast(), + texture: Point::ZERO, + }); + }); + let full = sweep == ZeroToOne::ONE; + if !events.is_empty() { + events.push(PathEvent::End { close: full }); + context.gfx.draw_shape( + &events + .into_iter() + .collect::>() + .stroke(StrokeOptions::px_wide(track_size).colored(color)), + ); + } + } +} + +impl Widget for Spinner { + fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { + let track_size = context.get(&TrackSize).into_px(context.gfx.scale()); + let start = self.start.get_tracking_refresh(context); + let end = self.end.get_tracking_refresh(context); + let size = context.gfx.region().size; + let render_size = size.width.min(size.height).into_float(); + let radius = render_size / 2. - track_size.into_float(); + let degree_offset = self.degree_offset.get(); + + if start > ZeroToOne::ZERO { + Self::draw_arc( + track_size, + radius, + degree_offset, + ZeroToOne::ZERO, + start, + context.get(&InactiveTrackColor), + context, + ); + } + + if start != end { + Self::draw_arc( + track_size, + radius, + degree_offset, + start, + ZeroToOne::new(*end - *start), + context.get(&TrackColor), + context, + ); + } + + if end < ZeroToOne::ONE { + Self::draw_arc( + track_size, + radius, + degree_offset, + end, + end.one_minus(), + context.get(&InactiveTrackColor), + context, + ); + } + } + + fn layout( + &mut self, + available_space: kludgine::figures::Size, + context: &mut crate::context::LayoutContext<'_, '_, '_, '_, '_>, + ) -> kludgine::figures::Size { + let track_size = context.get(&TrackSize).into_px(context.gfx.scale()); + let minimum_size = track_size * 4; + + available_space.map(|constraint| constraint.fit_measured(minimum_size, context.gfx.scale())) + } +}