Progress bars, repeating animations

Closes #70
This commit is contained in:
Jonathan Johnson 2023-11-21 09:53:08 -08:00
parent 2201f2c83b
commit 801337ab7a
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
7 changed files with 515 additions and 60 deletions

View file

@ -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()

View file

@ -171,6 +171,7 @@ pub trait Animate: Send + Sync {
}
/// A pending transition for a [`Dynamic`] to a new value.
#[derive(Clone)]
pub struct DynamicTransition<T> {
/// The dynamic value to change.
pub dynamic: Dynamic<T>,
@ -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<Target, Easing = Linear>
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<Self>
where
Self: Clone,
{
Cycle::forever(self)
}
/// Returns an animation that repeats a number of times before completing.
fn repeat(self, times: usize) -> Cycle<Self>
where
Self: Clone,
{
Cycle::n_times(times, self)
}
/// Invokes `on_complete` after this animation finishes.
fn on_complete<F>(self, on_complete: F) -> OnCompleteAnimation<Self>
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: IntoAnimate, B: IntoAnimate>(A, B);
/// A [`Chain`] that is currently animating.
@ -529,6 +548,90 @@ where
}
}
/// An animation that repeats another animation.
pub struct Cycle<A>
where
A: IntoAnimate + Clone,
{
cycles: Option<usize>,
animation: A,
running: Option<A::Animate>,
}
impl<A> Cycle<A>
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<A> IntoAnimate for Cycle<A>
where
A: IntoAnimate + Clone,
{
type Animate = Self;
fn into_animate(self) -> Self::Animate {
self
}
}
impl<A> Animate for Cycle<A>
where
A: IntoAnimate + Clone,
{
fn animate(&mut self, mut elapsed: Duration) -> ControlFlow<Duration> {
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

View file

@ -1201,6 +1201,16 @@ impl<T> Value<T> {
}
}
}
impl<T> IntoDynamic<T> for Value<T> {
fn into_dynamic(self) -> Dynamic<T> {
match self {
Value::Constant(value) => Dynamic::new(value),
Value::Dynamic(value) => value,
}
}
}
impl<T> Clone for Value<T>
where
T: Clone,

View file

@ -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;

49
src/widgets/data.rs Normal file
View file

@ -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<T> {
_data: T,
child: WidgetRef,
}
impl<T> Data<T> {
/// 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<T> From<T> for Data<T> {
fn from(value: T) -> Self {
Self::new(value)
}
}
impl<T> WrapperWidget for Data<T>
where
T: Debug + Send + UnwindSafe + 'static,
{
fn child_mut(&mut self) -> &mut WidgetRef {
&mut self.child
}
}

185
src/widgets/progress.rs Normal file
View file

@ -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<Progress>,
}
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<Progress>) -> 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<AnimationHandle>,
start: &Dynamic<ZeroToOne>,
end: &Dynamic<ZeroToOne>,
) {
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<T>: IntoDynamic<T> + 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<T>`. If
/// `None` is the maximum value, an indeterminant progress bar will be
/// displayed.
fn progress_bar_to(self, max: impl IntoValue<Option<T>>) -> 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<T>`. If
/// `None` is specified as the range, an indeterminant progress bar will be
/// displayed.
fn progress_bar_between<Range>(self, range: Range) -> ProgressBar
where
T: PercentBetween + Clone + Send + Sync + 'static,
Range: IntoValue<Option<RangeInclusive<T>>>,
{
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<U, T> Progressable<U> for T where T: IntoDynamic<U> {}

View file

@ -46,6 +46,8 @@ where
///
/// This defaults to `0.05`/5%.
pub step: Value<ZeroToOne>,
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<Px>,
start_knob: Option<Point<Px>>,
focus: Option<Knob>,
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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
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<Px>, _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<R>(&self, knobbed: impl FnOnce() -> R) -> R
where
R: Default,
{
if self.knob_size > 0 {
knobbed()
} else {
R::default()
}
}
}
fn flipped<T, Unit>(flip: bool, value: T) -> T
where
T: IntoComponents<Unit> + FromComponents<Unit>,