From 801337ab7ad2851dc2b36556960a8c9eac555f01 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 21 Nov 2023 09:53:08 -0800 Subject: [PATCH] Progress bars, repeating animations Closes #70 --- examples/animation.rs | 11 ++- src/animation.rs | 103 +++++++++++++++++++ src/value.rs | 10 ++ src/widgets.rs | 4 + src/widgets/data.rs | 49 +++++++++ src/widgets/progress.rs | 185 ++++++++++++++++++++++++++++++++++ src/widgets/slider.rs | 213 +++++++++++++++++++++++++++++----------- 7 files changed, 515 insertions(+), 60 deletions(-) create mode 100644 src/widgets/data.rs create mode 100644 src/widgets/progress.rs diff --git a/examples/animation.rs b/examples/animation.rs index a7eb101..c28e255 100644 --- a/examples/animation.rs +++ b/examples/animation.rs @@ -3,12 +3,13 @@ use std::time::Duration; use gooey::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn}; use gooey::value::Dynamic; use gooey::widget::MakeWidget; +use gooey::widgets::progress::Progressable; use gooey::{Run, WithClone}; +use kludgine::figures::units::Lp; fn main() -> gooey::Result { let animation = Dynamic::new(AnimationHandle::new()); let value = Dynamic::new(50); - let label = value.map_each(|value| value.to_string()); // Gooey's animation system supports using a `Duration` as a step in // animation to create a delay. This can also be used to call a function @@ -20,7 +21,13 @@ fn main() -> gooey::Result { "To 0" .into_button() .on_click(animate_to(&animation, &value, 0)) - .and(label) + .and( + value + .clone() + .progress_bar_to(100) + .width(Lp::inches(3)) + .centered(), + ) .and( "To 100" .into_button() diff --git a/src/animation.rs b/src/animation.rs index e2c8a0e..c096793 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -171,6 +171,7 @@ pub trait Animate: Send + Sync { } /// A pending transition for a [`Dynamic`] to a new value. +#[derive(Clone)] pub struct DynamicTransition { /// The dynamic value to change. pub dynamic: Dynamic, @@ -226,6 +227,7 @@ where /// [`Duration`], using the `Easing` generic parameter to control how the value /// is interpolated. #[must_use = "animations are not performed until they are spawned"] +#[derive(Clone)] pub struct Animation where Target: AnimationTarget, @@ -321,6 +323,22 @@ pub trait IntoAnimate: Sized + Send + Sync { Chain::new(self, other) } + /// Returns an animation that repeats `self` indefinitely. + fn cycle(self) -> Cycle + where + Self: Clone, + { + Cycle::forever(self) + } + + /// Returns an animation that repeats a number of times before completing. + fn repeat(self, times: usize) -> Cycle + where + Self: Clone, + { + Cycle::n_times(times, self) + } + /// Invokes `on_complete` after this animation finishes. fn on_complete(self, on_complete: F) -> OnCompleteAnimation where @@ -473,6 +491,7 @@ impl Drop for AnimationHandle { } /// An animation combinator that runs animation `A`, then animation `B`. +#[derive(Clone)] pub struct Chain(A, B); /// A [`Chain`] that is currently animating. @@ -529,6 +548,90 @@ where } } +/// An animation that repeats another animation. +pub struct Cycle +where + A: IntoAnimate + Clone, +{ + cycles: Option, + animation: A, + running: Option, +} + +impl Cycle +where + A: IntoAnimate + Clone, +{ + /// Returns a new animation that repeats `animation` an unlimited number of + /// times. + pub fn forever(animation: A) -> Self { + Self { + animation, + cycles: None, + running: None, + } + } + + /// Returns a new animation that repeats `animation` a specific number of + /// times. + /// + /// Passing 1 as `cycles` is equivalent to executing the animation directly. + pub fn n_times(cycles: usize, animation: A) -> Self { + Self { + animation, + cycles: Some(cycles), + running: None, + } + } + + fn keep_cycling(&mut self) -> bool { + match &mut self.cycles { + Some(0) => false, + Some(cycles) => { + *cycles -= 1; + true + } + None => true, + } + } +} + +impl IntoAnimate for Cycle +where + A: IntoAnimate + Clone, +{ + type Animate = Self; + + fn into_animate(self) -> Self::Animate { + self + } +} + +impl Animate for Cycle +where + A: IntoAnimate + Clone, +{ + fn animate(&mut self, mut elapsed: Duration) -> ControlFlow { + while !elapsed.is_zero() { + if let Some(running) = &mut self.running { + match running.animate(elapsed) { + ControlFlow::Break(remaining) => elapsed = remaining, + ControlFlow::Continue(()) => return ControlFlow::Continue(()), + } + } + + if self.keep_cycling() { + self.running = Some(self.animation.clone().into_animate()); + } else { + self.running = None; + return ControlFlow::Break(elapsed); + } + } + + ControlFlow::Continue(()) + } +} + /// An animation wrapper that invokes a callback upon the animation completing. /// /// This type guarantees the callback will only be invoked once per animation diff --git a/src/value.rs b/src/value.rs index 4d81840..2602f4e 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1201,6 +1201,16 @@ impl Value { } } } + +impl IntoDynamic for Value { + fn into_dynamic(self) -> Dynamic { + match self { + Value::Constant(value) => Dynamic::new(value), + Value::Dynamic(value) => value, + } + } +} + impl Clone for Value where T: Clone, diff --git a/src/widgets.rs b/src/widgets.rs index 079bbcf..04d42ab 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -6,10 +6,12 @@ mod canvas; pub mod checkbox; pub mod container; mod custom; +mod data; mod expand; pub mod input; pub mod label; mod mode_switch; +pub mod progress; mod resize; pub mod scroll; pub mod slider; @@ -26,10 +28,12 @@ pub use canvas::Canvas; pub use checkbox::Checkbox; pub use container::Container; pub use custom::Custom; +pub use data::Data; pub use expand::Expand; pub use input::Input; pub use label::Label; pub use mode_switch::ThemedMode; +pub use progress::ProgressBar; pub use resize::Resize; pub use scroll::Scroll; pub use slider::Slider; diff --git a/src/widgets/data.rs b/src/widgets/data.rs new file mode 100644 index 0000000..c8f8b7b --- /dev/null +++ b/src/widgets/data.rs @@ -0,0 +1,49 @@ +use std::fmt::Debug; +use std::panic::UnwindSafe; + +use crate::widget::{MakeWidget, WidgetRef, WrapperWidget}; +use crate::widgets::Space; + +/// A widget that stores arbitrary data in the widget hierachy. +/// +/// This widget is useful if data needs to live as long as a related widget. For +/// example, [`ProgressBar`](crate::widgets::ProgressBar) is not a "real" widget +/// -- it implements [`MakeWidget`] and returns a customized +/// [`Slider`](crate::widgets::Slider). To ensure the indeterminant animation +/// lives only as long as the created slider does, `ProgressBar` wraps the +/// `Slider` in a `Data` widget to store the animation handle. +#[derive(Debug)] +pub struct Data { + _data: T, + child: WidgetRef, +} + +impl Data { + /// Returns an empty widget with the contained value. + pub fn new(value: T) -> Self { + Self::new_wrapping(value, Space::clear()) + } + + /// Returns a new instance that wraps `widget` and stores `value`. + pub fn new_wrapping(value: T, widget: impl MakeWidget) -> Self { + Self { + _data: value, + child: WidgetRef::new(widget), + } + } +} + +impl From for Data { + fn from(value: T) -> Self { + Self::new(value) + } +} + +impl WrapperWidget for Data +where + T: Debug + Send + UnwindSafe + 'static, +{ + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.child + } +} diff --git a/src/widgets/progress.rs b/src/widgets/progress.rs new file mode 100644 index 0000000..02bba6e --- /dev/null +++ b/src/widgets/progress.rs @@ -0,0 +1,185 @@ +//! Widgets for displaying progress indicators. + +use std::ops::RangeInclusive; +use std::time::Duration; + +use kludgine::figures::Ranged; + +use crate::animation::easings::EaseInOutSine; +use crate::animation::{ + AnimationHandle, AnimationTarget, IntoAnimate, PercentBetween, Spawn, ZeroToOne, +}; +use crate::value::{Dynamic, IntoDynamic, IntoValue, MapEach, Value}; +use crate::widget::{MakeWidget, WidgetInstance}; +use crate::widgets::slider::Slidable; +use crate::widgets::Data; + +/// A bar-shaped progress indicator. +#[derive(Debug)] +pub struct ProgressBar { + progress: Value, +} + +impl ProgressBar { + /// Returns an indeterminant progress bar. + #[must_use] + pub const fn indeterminant() -> Self { + Self { + progress: Value::Constant(Progress::Indeterminant), + } + } + + /// Returns a new progress bar that displays `progress`. + #[must_use] + pub fn new(progress: impl IntoDynamic) -> Self { + Self { + progress: Value::Dynamic(progress.into_dynamic()), + } + } +} + +/// A measurement of progress for an indicator widget like [`ProgressBar`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Progress { + /// The task has an indeterminant length. + Indeterminant, + /// The task is a specified percent complete. + Percent(ZeroToOne), +} + +impl MakeWidget for ProgressBar { + fn make_widget(self) -> WidgetInstance { + let start = Dynamic::new(ZeroToOne::ZERO); + let end = Dynamic::new(ZeroToOne::ZERO); + let value = (&start, &end).map_each(|(start, end)| *start..=*end); + + let mut indeterminant_animation = None; + update_progress_bar( + self.progress.get(), + &mut indeterminant_animation, + &start, + &end, + ); + + let slider = value.slider().knobless().non_interactive(); + match self.progress { + Value::Dynamic(progress) => { + progress.for_each(move |progress| { + update_progress_bar(*progress, &mut indeterminant_animation, &start, &end); + }); + Data::new_wrapping(progress, slider).make_widget() + } + Value::Constant(_) => Data::new_wrapping(indeterminant_animation, slider).make_widget(), + } + } +} + +fn update_progress_bar( + progress: Progress, + indeterminant_animation: &mut Option, + start: &Dynamic, + end: &Dynamic, +) { + match progress { + Progress::Indeterminant => { + *indeterminant_animation = Some( + end.transition_to(ZeroToOne::new(0.66)) + .over(Duration::from_millis(500)) + .with_easing(EaseInOutSine) + .and_then( + start + .transition_to(ZeroToOne::new(0.33)) + .over(Duration::from_millis(500)) + .with_easing(EaseInOutSine), + ) + .and_then( + end.transition_to(ZeroToOne::ONE) + .over(Duration::from_millis(500)) + .with_easing(EaseInOutSine), + ) + .and_then( + start + .transition_to(ZeroToOne::ONE) + .over(Duration::from_millis(500)) + .with_easing(EaseInOutSine), + ) + .and_then( + ( + start.transition_to(ZeroToOne::ZERO), + end.transition_to(ZeroToOne::ZERO), + ) + .over(Duration::ZERO), + ) + .cycle() + .spawn(), + ); + } + Progress::Percent(value) => { + let _stopped_animation = indeterminant_animation.take(); + start.update(ZeroToOne::ZERO); + end.update(value); + } + } +} + +/// A value that can be used in a progress indicator. +pub trait Progressable: IntoDynamic + Sized { + /// Returns a new progress bar that displays progress from `T::MIN` to + /// `T::MAX`. + fn progress_bar(self) -> ProgressBar + where + T: Ranged + PercentBetween, + { + ProgressBar::new( + self.into_dynamic() + .map_each(|t| Progress::Percent(t.percent_between(&T::MIN, &T::MAX))), + ) + } + + /// Returns a new progress bar that displays progress from `T::MIN` to + /// `max`. The maximum value can be either a `T` or an `Option`. If + /// `None` is the maximum value, an indeterminant progress bar will be + /// displayed. + fn progress_bar_to(self, max: impl IntoValue>) -> ProgressBar + where + T: Ranged + PercentBetween + Clone + Send + Sync + 'static, + { + let max = max.into_value(); + match max { + Value::Constant(max) => self.progress_bar_between(max.map(|max| T::MIN..=max)), + Value::Dynamic(max) => { + self.progress_bar_between(max.map_each(|max| max.clone().map(|max| T::MIN..=max))) + } + } + } + + /// Returns a new progress bar that displays progress over the specified + /// `range` of `T`. The range can be either a `T..=T` or an `Option`. If + /// `None` is specified as the range, an indeterminant progress bar will be + /// displayed. + fn progress_bar_between(self, range: Range) -> ProgressBar + where + T: PercentBetween + Clone + Send + Sync + 'static, + Range: IntoValue>>, + { + let value = self.into_dynamic(); + let range = range.into_value(); + match range { + Value::Constant(range) => ProgressBar::new(value.map_each(move |value| { + range + .as_ref() + .map(|range| value.percent_between(range.start(), range.end())) + .map_or(Progress::Indeterminant, Progress::Percent) + })), + Value::Dynamic(range) => { + ProgressBar::new((&range, &value).map_each(|(range, value)| { + range.clone().map_or(Progress::Indeterminant, |range| { + Progress::Percent(value.percent_between(range.start(), range.end())) + }) + })) + } + } + } +} + +impl Progressable for T where T: IntoDynamic {} diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index 6ed7bae..4c7e51c 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -46,6 +46,8 @@ where /// /// This defaults to `0.05`/5%. pub step: Value, + knob_visible: bool, + interactive: bool, knob_size: UPx, horizontal: bool, rendered_size: Px, @@ -88,6 +90,8 @@ where value: value.into_dynamic(), minimum: min.into_value(), maximum: max.into_value(), + knob_visible: true, + interactive: true, step: Value::Constant(ZeroToOne::new(0.05)), knob_size: UPx::ZERO, horizontal: true, @@ -126,15 +130,32 @@ where self } + /// Updates this slider to not show knobs and returns self. + /// + /// This also prevents the slider from being focused. + #[must_use] + pub fn knobless(mut self) -> Self { + self.knob_visible = false; + self + } + + /// Updates this slider to ignore all user input and returns self. + #[must_use] + pub fn non_interactive(mut self) -> Self { + self.interactive = false; + self + } + fn draw_track(&mut self, spec: &TrackSpec, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { if self.horizontal { self.rendered_size = spec.size.width; } else { self.rendered_size = spec.size.height; } - let half_focus_ring = (Lp::points(2).into_px(context.gfx.scale()) / 2).ceil(); + let half_focus_ring = + spec.if_knobbed(|| (Lp::points(2).into_px(context.gfx.scale()) / 2).ceil()); let focus_ring = half_focus_ring * 2; - let track_length = self.rendered_size - spec.knob_size - focus_ring; + let track_length = self.rendered_size - spec.if_knobbed(|| spec.knob_size - focus_ring); let (start, end) = if let Some(end) = spec.end { (track_length * spec.start, track_length * end) } else { @@ -143,15 +164,13 @@ where let inset = Point::squared(half_focus_ring); let half_track = spec.track_size / 2; + let start_inset = (spec.half_knob - half_track).max(Px::ZERO); // Draw the track if start > 0 { context.gfx.draw_shape( Shape::filled_round_rect( Rect::new( - flipped( - !self.horizontal, - Point::new(spec.half_knob - half_track, spec.half_knob - half_track), - ), + flipped(!self.horizontal, Point::new(start_inset, start_inset)), flipped(!self.horizontal, Size::new(start, spec.track_size)), ), half_track, @@ -166,11 +185,14 @@ where Rect::new( flipped( !self.horizontal, - Point::new(end + spec.half_knob, spec.half_knob - half_track), + Point::new(end + spec.if_knobbed(|| spec.half_knob), start_inset), ), flipped( !self.horizontal, - Size::new(track_length - end + half_track, spec.track_size), + Size::new( + track_length - end + spec.if_knobbed(|| half_track), + spec.track_size, + ), ), ), half_track, @@ -187,13 +209,16 @@ where flipped( !self.horizontal, Point::new( - start + spec.half_knob - half_track, - spec.half_knob - half_track, + start + spec.if_knobbed(|| spec.half_knob - half_track), + start_inset, ), ), flipped( !self.horizontal, - Size::new(end - start + spec.track_size, spec.track_size), + Size::new( + end - start + spec.if_knobbed(|| spec.track_size), + spec.track_size, + ), ), ), half_track, @@ -204,34 +229,73 @@ where } // Draw the knob - let focused = context.focused(); - let this_knob_role = if spec.end.is_some() { - Knob::End - } else { - Knob::Start - }; - self.draw_knob( - flipped( - !self.horizontal, - Point::new(end + spec.half_knob, spec.half_knob) + inset, - ), - focused && self.focused_knob == Some(this_knob_role), - focus_ring, - spec, - context, - ); - - if spec.end.is_some() { - self.draw_knob( + if spec.knob_size > 0 { + let focus = context.focused().then_some(self.focused_knob).flatten(); + self.draw_knobs( flipped( !self.horizontal, - Point::new(start + spec.half_knob, spec.half_knob) + inset, + Point::new(end + spec.half_knob, spec.half_knob) + inset, ), - focused && matches!(self.focused_knob, Some(Knob::Start)), + spec.end.map(|_| { + flipped( + !self.horizontal, + Point::new(start + spec.half_knob, spec.half_knob) + inset, + ) + }), + focus, focus_ring, spec, context, ); + // let this_knob_role = if spec.end.is_some() { + // Knob::End + // } else { + // Knob::Start + // }; + // self.draw_knob( + // flipped( + // !self.horizontal, + // Point::new(end + spec.half_knob, spec.half_knob) + inset, + // ), + // focused && self.focused_knob == Some(this_knob_role), + // focus_ring, + // spec, + // context, + // ); + + // if spec.end.is_some() { + // self.draw_knob( + // flipped( + // !self.horizontal, + // Point::new(start + spec.half_knob, spec.half_knob) + inset, + // ), + // focused && matches!(self.focused_knob, Some(Knob::Start)), + // focus_ring, + // spec, + // context, + // ); + // } + } + } + + fn draw_knobs( + &mut self, + end_knob: Point, + start_knob: Option>, + focus: Option, + focus_ring_width: Px, + spec: &TrackSpec, + context: &mut GraphicsContext<'_, '_, '_, '_, '_>, + ) { + let (a, a_is_focused, b) = match (start_knob, focus) { + (Some(start_knob), Some(Knob::Start)) => (end_knob, false, Some((start_knob, true))), + (Some(start_knob), focus) => (start_knob, false, Some((end_knob, focus.is_some()))), + (None, focus) => (end_knob, focus.is_some(), None), + }; + + self.draw_knob(a, a_is_focused, focus_ring_width, spec, context); + if let Some((b, b_is_focused)) = b { + self.draw_knob(b, b_is_focused, focus_ring_width, spec, context); } } @@ -291,20 +355,16 @@ where let end_percent = end.percent_between(&min, &max); let knob_width_as_percent = self.knob_size.into_float() / 2. / track_width.into_float(); - if percent - *start_percent <= knob_width_as_percent - && matches!(previous_focus, Some(Knob::Start)) - { + let start_delta = percent - *start_percent; + let end_delta = *end_percent - percent; + let on_overlapping_knobs = + end_delta <= knob_width_as_percent && start_delta <= knob_width_as_percent; + if let (true, Some(previous)) = (on_overlapping_knobs, previous_focus) { + previous + } else if start_delta < end_delta { Knob::Start - } else if *end_percent - percent <= knob_width_as_percent - && matches!(previous_focus, Some(Knob::End)) - { - Knob::End - } else if value <= start { - Knob::Start - } else if &value >= end { - Knob::End } else { - Knob::Start + Knob::End } }; match knob { @@ -390,10 +450,10 @@ where let inactive_track_color = context.get(&InactiveTrackColor); let knob_color = context.get(&KnobColor); let knob_size = self.knob_size.into_signed(); - let track_size = context - .get(&TrackSize) - .into_px(context.gfx.scale()) - .min(knob_size); + let mut track_size = context.get(&TrackSize).into_px(context.gfx.scale()); + if knob_size > 0 { + track_size = track_size.min(knob_size); + } let half_knob = knob_size / 2; @@ -460,12 +520,24 @@ where available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - self.knob_size = context.get(&KnobSize).into_upx(context.gfx.scale()); + self.knob_size = if self.knob_visible { + context.get(&KnobSize).into_upx(context.gfx.scale()) + } else { + UPx::ZERO + }; let minimum_size = context .get(&MinimumSliderSize) .into_upx(context.gfx.scale()); - let focus_ring_width = (Lp::points(2).into_upx(context.gfx.scale()) / 2).ceil() * 2; - let focused_knob_size = self.knob_size + focus_ring_width; + let focus_ring_width = if self.knob_visible { + (Lp::points(2).into_upx(context.gfx.scale()) / 2).ceil() * 2 + } else { + UPx::ZERO + }; + let static_side = if self.knob_visible { + self.knob_size + focus_ring_width + } else { + context.get(&TrackSize).into_upx(context.gfx.scale()) + }; match (available_space.width, available_space.height) { (ConstraintLimit::Fill(width), ConstraintLimit::Fill(height)) => { @@ -473,17 +545,17 @@ where // up with a horizontal slider. if width < height { // Vertical slider - Size::new(focused_knob_size, height.max(minimum_size)) + Size::new(static_side, height.max(minimum_size)) } else { // Horizontal slider - Size::new(width.max(minimum_size), focused_knob_size) + Size::new(width.max(minimum_size), static_side) } } (ConstraintLimit::Fill(width), ConstraintLimit::SizeToFit(_)) => { - Size::new(width.max(minimum_size), focused_knob_size) + Size::new(width.max(minimum_size), static_side) } (ConstraintLimit::SizeToFit(_), ConstraintLimit::Fill(height)) => { - Size::new(focused_knob_size, height.max(minimum_size)) + Size::new(static_side, height.max(minimum_size)) } (ConstraintLimit::SizeToFit(width), ConstraintLimit::SizeToFit(_)) => { // When we have no limit on our, we still want to be draggable. @@ -493,17 +565,17 @@ where // user of the slider, a horizontal slider is expected. So, we // set the minimum measurement based on a horizontal // orientation. - Size::new(width.min(minimum_size), focused_knob_size) + Size::new(width.min(minimum_size), static_side) } } } fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) -> bool { - true + self.interactive } fn accept_focus(&mut self, context: &mut EventContext<'_, '_>) -> bool { - context.get(&AutoFocusableControls).is_all() + self.interactive && self.knob_visible && context.get(&AutoFocusableControls).is_all() } fn focus(&mut self, context: &mut EventContext<'_, '_>) { @@ -556,6 +628,10 @@ where _button: MouseButton, context: &mut EventContext<'_, '_>, ) -> EventHandling { + let true = self.interactive else { + return IGNORED; + }; + let previous_focus = match (self.previous_focus.take(), self.focused_knob.take()) { (None | Some(_), Some(focus)) | (Some(focus), None) => Some(focus), (None, None) => None, @@ -593,6 +669,10 @@ where _is_synthetic: bool, _context: &mut EventContext<'_, '_>, ) -> EventHandling { + let true = self.interactive else { + return IGNORED; + }; + let forwards = match input.logical_key { Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => false, Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown) => true, @@ -614,6 +694,10 @@ where _phase: TouchPhase, _context: &mut EventContext<'_, '_>, ) -> EventHandling { + let true = self.interactive else { + return IGNORED; + }; + let factor: f32 = match delta { MouseScrollDelta::LineDelta(_, y) => y, MouseScrollDelta::PixelDelta(pt) => pt.y.cast(), @@ -645,6 +729,19 @@ struct TrackSpec { inactive_track_color: Color, } +impl TrackSpec { + fn if_knobbed(&self, knobbed: impl FnOnce() -> R) -> R + where + R: Default, + { + if self.knob_size > 0 { + knobbed() + } else { + R::default() + } + } +} + fn flipped(flip: bool, value: T) -> T where T: IntoComponents + FromComponents,