diff --git a/Cargo.lock b/Cargo.lock index 73b935b..b7855da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,9 +455,9 @@ dependencies = [ [[package]] name = "etagere" -version = "0.2.9" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf70b9ea3a235a7432b4f481854815e2d4fb2fe824c1f5fb09b8985dd06b3e9" +checksum = "fcf22f748754352918e082e0039335ee92454a5d62bcaf69b5e8daf5907d9644" dependencies = [ "euclid", "svg_fmt", @@ -481,7 +481,7 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] name = "figures" version = "0.1.0" -source = "git+https://github.com/khonsulabs/figures#f5b9ca5cf181b748897b269ad47d7a9f2d1f3eac" +source = "git+https://github.com/khonsulabs/figures#3e12470aa10d36d0b38f0441a0e89ae03ccd448b" dependencies = [ "bytemuck", "euclid", diff --git a/examples/theme.rs b/examples/theme.rs index 2bd8cd5..bf85b7b 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -1,14 +1,15 @@ +use gooey::animation::ZeroToOne; use gooey::styles::components::{TextColor, WidgetBackground}; use gooey::styles::{ColorSource, ColorTheme, FixedTheme, SurfaceTheme, Theme, ThemePair}; use gooey::value::{Dynamic, MapEach}; use gooey::widget::MakeWidget; -use gooey::widgets::{Input, Label, Stack}; +use gooey::widgets::{Label, Scroll, Slider, Stack}; use gooey::Run; use kludgine::Color; -const PRIMARY_HUE: f32 = -120.; +const PRIMARY_HUE: f32 = 240.; const SECONDARY_HUE: f32 = 0.; -const TERTIARY_HUE: f32 = -30.; +const TERTIARY_HUE: f32 = 330.; const ERROR_HUE: f32 = 30.; fn main() -> gooey::Result { @@ -41,23 +42,21 @@ fn main() -> gooey::Result { }, ); - Stack::rows( - Stack::columns( + Stack::columns( + Scroll::vertical(Stack::rows( primary_editor .and(secondary_editor) .and(tertiary_editor) .and(error_editor) .and(neutral_editor) .and(neutral_variant_editor), - ) - .and(Stack::columns( - theme(default_theme.map_each(|theme| theme.dark), "Dark") - .and(theme(default_theme.map_each(|theme| theme.light), "Light")) - .and(fixed_themes( - default_theme.map_each(|theme| theme.primary_fixed), - default_theme.map_each(|theme| theme.secondary_fixed), - default_theme.map_each(|theme| theme.tertiary_fixed), - )), + )) + .and(theme(default_theme.map_each(|theme| theme.dark), "Dark")) + .and(theme(default_theme.map_each(|theme| theme.light), "Light")) + .and(fixed_themes( + default_theme.map_each(|theme| theme.primary_fixed), + default_theme.map_each(|theme| theme.secondary_fixed), + default_theme.map_each(|theme| theme.tertiary_fixed), )), ) .expand() @@ -66,13 +65,14 @@ fn main() -> gooey::Result { fn color_editor( initial_hue: f32, - initial_saturation: f32, + initial_saturation: impl Into, label: &str, ) -> (Dynamic, impl MakeWidget) { - let hue_text = Dynamic::new(initial_hue.to_string()); - let hue = hue_text.map_each(|hue| hue.parse::().unwrap_or_default()); - let saturation_text = Dynamic::new(initial_saturation.to_string()); - let saturation = saturation_text.map_each(|sat| sat.parse::().unwrap_or_default()); + let hue = Dynamic::new(initial_hue); + let hue_text = hue.map_each(|hue| hue.to_string()); + let saturation = Dynamic::new(initial_saturation.into()); + let saturation_text = saturation.map_each(|saturation| saturation.to_string()); + let color = (&hue, &saturation).map_each(|(hue, saturation)| ColorSource::new(*hue, *saturation)); @@ -80,10 +80,11 @@ fn color_editor( color, Stack::rows( Label::new(label) - .and(Input::new(hue_text)) - .and(Input::new(saturation_text)), - ) - .expand(), + .and(Slider::::new(hue, 0., 360.)) + .and(Label::new(hue_text)) + .and(Slider::::from_value(saturation)) + .and(Label::new(saturation_text)), + ), ) } diff --git a/src/animation.rs b/src/animation.rs index 3c56536..97234d9 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -40,14 +40,16 @@ pub mod easings; use std::fmt::Debug; -use std::ops::{ControlFlow, Deref}; +use std::ops::{ControlFlow, Deref, Div, Mul}; use std::panic::{RefUnwindSafe, UnwindSafe}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError}; use std::thread; use std::time::{Duration, Instant}; use alot::{LotId, Lots}; +use intentional::Cast; use kempt::Set; +use kludgine::figures::Ranged; use kludgine::Color; use crate::animation::easings::Linear; @@ -703,6 +705,56 @@ impl LinearInterpolate for Color { } } +/// Calculates the ratio of one value against a minimum and maximum. +pub trait PercentBetween { + /// Return the percentage that `self` is between `min` and `max`. + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne; +} + +macro_rules! impl_percent_between { + ($type:ident, $float:ident) => { + impl PercentBetween for $type { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + let range = *max - *min; + ZeroToOne::from(*self as $float / range as $float) + } + } + }; +} + +impl_percent_between!(u8, f32); +impl_percent_between!(u16, f32); +impl_percent_between!(u32, f32); +impl_percent_between!(u64, f32); +impl_percent_between!(u128, f64); +impl_percent_between!(usize, f64); +impl_percent_between!(i8, f32); +impl_percent_between!(i16, f32); +impl_percent_between!(i32, f32); +impl_percent_between!(i64, f32); +impl_percent_between!(i128, f64); +impl_percent_between!(isize, f64); +impl_percent_between!(f32, f32); +impl_percent_between!(f64, f64); + +impl PercentBetween for Color { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + fn channel_percent( + value: Color, + min: Color, + max: Color, + func: impl Fn(Color) -> u8, + ) -> ZeroToOne { + func(value).percent_between(&func(min), &func(max)) + } + + channel_percent(*self, *min, *max, Color::red) + * channel_percent(*self, *min, *max, Color::green) + * channel_percent(*self, *min, *max, Color::blue) + * channel_percent(*self, *min, *max, Color::alpha) + } +} + /// An `f32` that is clamped between 0.0 and 1.0 and cannot be NaN or Infinity. /// /// Because of these restrictions, this type implements `Ord` and `Eq`. @@ -734,6 +786,18 @@ impl ZeroToOne { } } +impl From for ZeroToOne { + fn from(value: f32) -> Self { + Self::new(value) + } +} + +impl From for ZeroToOne { + fn from(value: f64) -> Self { + Self::new(value.cast()) + } +} + impl Default for ZeroToOne { fn default() -> Self { Self::ZERO @@ -787,6 +851,33 @@ impl LinearInterpolate for ZeroToOne { } } +impl PercentBetween for ZeroToOne { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + self.0.percent_between(&min.0, &max.0) + } +} + +impl Mul for ZeroToOne { + type Output = Self; + + fn mul(self, rhs: Self) -> Self::Output { + Self(self.0 * rhs.0) + } +} + +impl Div for ZeroToOne { + type Output = Self; + + fn div(self, rhs: Self) -> Self::Output { + Self(self.0 / rhs.0) + } +} + +impl Ranged for ZeroToOne { + const MAX: Self = Self::ONE; + const MIN: Self = Self::ZERO; +} + /// An easing function for customizing animations. #[derive(Debug, Clone)] pub enum EasingFunction { diff --git a/src/lib.rs b/src/lib.rs index 0841d52..7ace2ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,10 +47,10 @@ impl ConstraintLimit { } /// Converts `measured` to unsigned pixels, and adjusts it according to the - /// contraint's intentions. + /// constraint's intentions. /// /// If this constraint is of a known size, it will return the maximum of the - /// measured size and the contraint. If it is of an unknown size, it will + /// measured size and the constraint. If it is of an unknown size, it will /// return the measured size. pub fn fit_measured(self, measured: Unit, scale: Fraction) -> UPx where diff --git a/src/styles.rs b/src/styles.rs index 7c1c209..e9476cf 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -571,9 +571,15 @@ where pub struct Group(Name); impl Group { + /// Returns a new group with `name`. + #[must_use] + pub fn new(name: impl Into>) -> Self { + Self(Name::new(name)) + } + /// Returns a new instance using the group name of `T`. #[must_use] - pub fn new() -> Self + pub fn from_group() -> Self where T: ComponentGroup, { @@ -625,7 +631,7 @@ impl ComponentName { /// Returns a new instance using `G` and `name`. pub fn named(name: impl Into) -> Self { - Self::new(Group::new::(), name) + Self::new(Group::from_group::(), name) } } @@ -1130,10 +1136,10 @@ impl ColorSource { /// Returns a new source with the given hue (in degrees) and saturation (0.0 /// - 1.0). #[must_use] - pub fn new(hue: f32, saturation: f32) -> Self { + pub fn new(hue: impl Into, saturation: impl Into) -> Self { Self { - hue: OklabHue::new(hue), - saturation: ZeroToOne::new(saturation), + hue: hue.into(), + saturation: saturation.into(), } } diff --git a/src/value.rs b/src/value.rs index 4434039..48d835c 100644 --- a/src/value.rs +++ b/src/value.rs @@ -139,6 +139,18 @@ impl Dynamic { self.0.get().value } + /// Returns a clone of the currently contained value. + /// + /// `context` will be invalidated when the value is updated. + #[must_use] + pub fn get_tracked(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + context.redraw_when_changed(self); + self.get() + } + /// Returns the currently stored value, replacing the current contents with /// `T::default()`. #[must_use] @@ -669,6 +681,20 @@ impl Value { } } + /// Maps the current contents to `map` and returns the result. + /// + /// If `self` is a dynamic, `context` will be invalidated when the value is + /// updated. + pub fn map_tracked(&self, context: &WidgetContext<'_, '_>, map: impl FnOnce(&T) -> R) -> R { + match self { + Value::Constant(value) => map(value), + Value::Dynamic(dynamic) => { + context.redraw_when_changed(dynamic); + dynamic.map_ref(map) + } + } + } + /// Maps the current contents with exclusive access and returns the result. pub fn map_mut(&mut self, map: impl FnOnce(&mut T) -> R) -> R { match self { @@ -685,6 +711,17 @@ impl Value { self.map(Clone::clone) } + /// Returns a clone of the currently stored value. + /// + /// If `self` is a dynamic, `context` will be invalidated when the value is + /// updated. + pub fn get_tracked(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.map_tracked(context, Clone::clone) + } + /// Returns the current generation of the data stored, if the contained /// value is [`Dynamic`]. pub fn generation(&self) -> Option { diff --git a/src/widgets.rs b/src/widgets.rs index aa64c5a..4047986 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -8,6 +8,7 @@ mod input; pub mod label; mod resize; pub mod scroll; +mod slider; mod space; pub mod stack; mod style; @@ -21,6 +22,7 @@ pub use input::Input; pub use label::Label; pub use resize::Resize; pub use scroll::Scroll; +pub use slider::Slider; pub use space::Space; pub use stack::Stack; pub use style::Style; diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index 58b8e09..bed39da 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -193,12 +193,12 @@ impl Widget for Scroll { if self.enabled.x { ConstraintLimit::ClippedAfter(UPx::MAX - scroll.x.into_unsigned()) } else { - ConstraintLimit::Known(control_size.width.into_unsigned()) + available_space.width }, if self.enabled.y { ConstraintLimit::ClippedAfter(UPx::MAX - scroll.y.into_unsigned()) } else { - ConstraintLimit::Known(control_size.height.into_unsigned()) + available_space.height }, ); let managed = self.contents.mounted(&mut context.as_event_context()); @@ -255,7 +255,22 @@ impl Widget for Scroll { ); context.set_child_layout(&managed, region); - Size::new(available_space.width.max(), available_space.height.max()) + Size::new( + if self.enabled.x { + available_space + .width + .fit_measured(self.content_size.width, context.gfx.scale()) + } else { + self.content_size.width.into_unsigned() + }, + if self.enabled.y { + available_space + .height + .fit_measured(self.content_size.height, context.gfx.scale()) + } else { + self.content_size.height.into_unsigned() + }, + ) } fn mouse_wheel( diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs new file mode 100644 index 0000000..508ae3d --- /dev/null +++ b/src/widgets/slider.rs @@ -0,0 +1,349 @@ +use std::borrow::Cow; +use std::fmt::Debug; +use std::panic::UnwindSafe; + +use kludgine::app::winit::event::{DeviceId, MouseButton}; +use kludgine::figures::units::{Lp, Px, UPx}; +use kludgine::figures::{ + FloatConversion, IntoSigned, IntoUnsigned, Point, Ranged, Rect, ScreenScale, Size, +}; +use kludgine::shapes::Shape; +use kludgine::{Color, Origin}; + +use crate::animation::{LinearInterpolate, PercentBetween}; +use crate::context::{EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::styles::{ComponentDefinition, ComponentName, Dimension, Group, NamedComponent}; +use crate::value::{Dynamic, IntoDynamic, IntoValue, Value}; +use crate::widget::{EventHandling, Widget, HANDLED}; +use crate::ConstraintLimit; + +/// A widget that allows sliding between two values. +#[derive(Debug, Clone)] +pub struct Slider { + /// The current value. + pub value: Dynamic, + /// The minimum value represented by this slider. + pub minimum: Value, + /// The maximum value represented by this slider. + pub maximum: Value, + knob_size: UPx, + horizontal: bool, + rendered_size: Px, +} + +impl Slider +where + T: Ranged, +{ + /// Returns a new slider over `value` using the types full range. + #[must_use] + pub fn from_value(value: impl IntoDynamic) -> Self { + Self::new(value, T::MIN, T::MAX) + } +} + +impl Slider { + /// Returns a new slider using `value` as the slider's value, keeping the + /// value between `min` and `max`. + #[must_use] + pub fn new(value: impl IntoDynamic, min: impl IntoValue, max: impl IntoValue) -> Self { + Self { + value: value.into_dynamic(), + minimum: min.into_value(), + maximum: max.into_value(), + knob_size: UPx(0), + horizontal: true, + rendered_size: Px(0), + } + } + + /// Sets the maximum value of this slider to `max` and returns self. + #[must_use] + pub fn maximum(mut self, max: impl IntoValue) -> Self { + self.maximum = max.into_value(); + self + } + + /// Sets the minimum value of this slider to `min` and returns self. + #[must_use] + pub fn minimum(mut self, min: impl IntoValue) -> Self { + self.minimum = min.into_value(); + self + } +} + +impl Slider +where + T: LinearInterpolate + Clone, +{ + fn update_from_click(&mut self, position: Point) { + let position = if self.horizontal { + position.x + } else { + position.y + }; + let position = position.clamp(Px(0), self.rendered_size); + let percent = position.into_float() / self.rendered_size.into_float(); + let min = self.minimum.get(); + let max = self.maximum.get(); + self.value.update(min.lerp(&max, percent)); + } +} + +impl Widget for Slider +where + T: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + let styles = context.query_styles(&[&TrackColor, &KnobColor, &TrackSize]); + let track_color = styles.get(&TrackColor, context); + let knob_color = styles.get(&KnobColor, context); + let knob_size = self.knob_size.into_signed(); + let track_size = styles + .get(&TrackSize, context) + .into_px(context.gfx.scale()) + .min(knob_size); + + let half_knob = knob_size / 2; + let half_track = track_size / 2; + + let mut value = self.value.get_tracked(context); + let min = self.minimum.get_tracked(context); + let mut max = self.maximum.get_tracked(context); + + if max < min { + self.maximum.map_mut(|max| *max = min.clone()); + max = min.clone(); + } + let mut value_clamped = false; + if value < min { + value_clamped = true; + value = min.clone(); + } else if value > max { + value_clamped = true; + value = max.clone(); + } + + if value_clamped { + self.value.map_mut(|v| *v = value.clone()); + } + + let percent = value.percent_between(&min, &max); + + let size = context.gfx.region().size; + self.horizontal = size.width >= size.height; + + if self.horizontal { + self.rendered_size = size.width; + // Draw the track + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new( + Point::new(half_knob, half_knob - half_track), + Size::new(size.width - knob_size, track_size), + ), + track_color, + ), + Point::default(), + None, + None, + ); + + // Draw the knob + context.gfx.draw_shape( + &Shape::filled_circle(half_knob, knob_color, Origin::Center), + Point::new(half_knob + (size.width - knob_size) * *percent, half_knob), + None, + None, + ); + } else { + // Vertical slider + self.rendered_size = size.height; + + // Draw the track + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new( + Point::new(half_knob - half_track, half_knob), + Size::new(track_size, size.height - knob_size), + ), + track_color, + ), + Point::default(), + None, + None, + ); + + // Draw the knob + context.gfx.draw_shape( + &Shape::filled_circle(half_knob, knob_color, Origin::Center), + Point::new(half_knob, half_knob + (size.height - knob_size) * *percent), + None, + None, + ); + } + } + + fn layout( + &mut self, + available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> Size { + let styles = context.query_styles(&[&KnobSize, &MinimumSliderSize]); + self.knob_size = styles + .get(&KnobSize, context) + .into_px(context.gfx.scale()) + .into_unsigned(); + let minimum_size = styles + .get(&MinimumSliderSize, context) + .into_px(context.gfx.scale()) + .into_unsigned(); + + match (available_space.width, available_space.height) { + (ConstraintLimit::Known(width), ConstraintLimit::Known(height)) => { + // This comparison is done such that if width == height, we end + // up with a horizontal slider. + if width < height { + // Vertical slider + Size::new(self.knob_size, height.max(minimum_size)) + } else { + // Horizontal slider + Size::new(width.max(minimum_size), self.knob_size) + } + } + (ConstraintLimit::Known(width), ConstraintLimit::ClippedAfter(_)) => { + Size::new(width.max(minimum_size), self.knob_size) + } + (ConstraintLimit::ClippedAfter(_), ConstraintLimit::Known(height)) => { + Size::new(self.knob_size, height.max(minimum_size)) + } + (ConstraintLimit::ClippedAfter(width), ConstraintLimit::ClippedAfter(_)) => { + // When we have no limit on our, we still want to be draggable. + // Since we have no limit in both directions, we have to make a + // choice: horizontal or vertical. It seems to @ecton at the + // time of writing this that when there is no intent from the + // 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), self.knob_size) + } + } + } + + fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) -> bool { + true + } + + fn mouse_down( + &mut self, + location: Point, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_, '_>, + ) -> EventHandling { + self.update_from_click(location); + HANDLED + } + + fn mouse_drag( + &mut self, + location: Point, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_, '_>, + ) { + self.update_from_click(location); + } +} + +/// The size of the track that the knob of a [`Slider`] traversesq. +pub struct TrackSize; + +impl ComponentDefinition for TrackSize { + type ComponentType = Dimension; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Self::ComponentType { + Dimension::Lp(Lp::points(5)) + } +} + +impl NamedComponent for TrackSize { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "track_size")) + } +} + +/// The width and height of the draggable portion of a [`Slider`]. +pub struct KnobSize; + +impl ComponentDefinition for KnobSize { + type ComponentType = Dimension; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Self::ComponentType { + Dimension::Lp(Lp::points(14)) + } +} + +impl NamedComponent for KnobSize { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "knob_size")) + } +} + +/// The minimum length of the slidable dimension. +pub struct MinimumSliderSize; + +impl ComponentDefinition for MinimumSliderSize { + type ComponentType = Dimension; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Self::ComponentType { + Dimension::Lp(Lp::points(14)) + } +} + +impl NamedComponent for MinimumSliderSize { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "minimum_size")) + } +} + +/// The color of the draggable portion of the knob. +pub struct KnobColor; + +impl ComponentDefinition for KnobColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType { + context.theme().primary.color + } +} + +impl NamedComponent for KnobColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "knob_color")) + } +} + +/// The color of the draggable portion of the knob. +pub struct TrackColor; + +impl ComponentDefinition for TrackColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType { + context.theme().surface.on_color_variant + } +} + +impl NamedComponent for TrackColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "track_color")) + } +}