From 2201f2c83b87efd7bf2eab76d65d9118b0ab7d3d Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Mon, 20 Nov 2023 19:44:03 -0800 Subject: [PATCH] Ranged sliders, advance_focus, allow_blur Closes #60 Stepping in sliders is a compromise due to the flexibility of the current slider implementation. I don't want to force types to implement Add, and I don't like forcing types to require a Step (ie, what's the appropriate value for f32 to specify as its next value?). Using a percentage combined with lerp keeps the implementation fairly straightfoward, although I remember experiencing this type of configuration in another UI framework a long time ago and thinking it was a little annoying to work with. Ultimately, setting actual step boundaries can be done by customizing the type that the slider is operating over. I feel like that's a much more powerful design than I've experienced in previous frameworks, so I'm hoping this percent step behavior is a reasonable compromise. --- Cargo.lock | 87 ++---- examples/focus.rs | 35 +++ examples/slider.rs | 46 +++- src/context.rs | 38 ++- src/widget.rs | 56 +++- src/widgets/custom.rs | 61 +++++ src/widgets/slider.rs | 621 +++++++++++++++++++++++++++++++++++------- src/window.rs | 1 + 8 files changed, 769 insertions(+), 176 deletions(-) create mode 100644 examples/focus.rs diff --git a/Cargo.lock b/Cargo.lock index cc4a83b..40c160d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,9 +106,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac57f2b058a76363e357c056e4f74f1945bf734d37b8b3ef49066c4787dde0fc" +checksum = "aafb29b107435aa276664c1db8954ac27a6e105cdad3c88287a199eb0e313c08" dependencies = [ "clipboard-win", "core-graphics 0.22.3", @@ -120,7 +120,7 @@ dependencies = [ "parking_lot", "thiserror", "winapi", - "x11rb 0.10.1", + "x11rb", ] [[package]] @@ -757,16 +757,6 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" -[[package]] -name = "gethostname" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "gethostname" version = "0.3.0" @@ -1086,7 +1076,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#267c42d9454b31ebfe944e49522da33811e93772" +source = "git+https://github.com/khonsulabs/kludgine#138997a46158d96509c466751d95d80988c6d7bd" dependencies = [ "ahash", "alot", @@ -1301,15 +1291,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.7.1" @@ -1410,18 +1391,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "nix" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.26.4" @@ -1431,7 +1400,7 @@ dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", - "memoffset 0.7.1", + "memoffset", ] [[package]] @@ -2021,18 +1990,18 @@ checksum = "e388332cd64eb80cd595a00941baf513caffae8dce9cfd0467fc9c66397dade6" [[package]] name = "serde" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.192" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", @@ -2508,7 +2477,7 @@ checksum = "19152ddd73f45f024ed4534d9ca2594e0ef252c1847695255dae47f34df9fbe4" dependencies = [ "cc", "downcast-rs", - "nix 0.26.4", + "nix", "scoped-tls", "smallvec", "wayland-sys", @@ -2521,7 +2490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ca7d52347346f5473bf2f56705f360e8440873052e575e55890c4fa57843ed3" dependencies = [ "bitflags 2.4.1", - "nix 0.26.4", + "nix", "wayland-backend", "wayland-scanner", ] @@ -2543,7 +2512,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44aa20ae986659d6c77d64d808a046996a932aa763913864dc40c359ef7ad5b" dependencies = [ - "nix 0.26.4", + "nix", "wayland-client", "xcursor", ] @@ -2977,7 +2946,7 @@ dependencies = [ "web-time", "windows-sys 0.48.0", "x11-dl", - "x11rb 0.12.0", + "x11rb", "xkbcommon-dl", ] @@ -3001,19 +2970,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x11rb" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "592b4883219f345e712b3209c62654ebda0bb50887f330cbd018d0f654bfd507" -dependencies = [ - "gethostname 0.2.3", - "nix 0.24.3", - "winapi", - "winapi-wsapoll", - "x11rb-protocol 0.10.0", -] - [[package]] name = "x11rb" version = "0.12.0" @@ -3021,23 +2977,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" dependencies = [ "as-raw-xcb-connection", - "gethostname 0.3.0", + "gethostname", "libc", "libloading 0.7.4", - "nix 0.26.4", + "nix", "once_cell", "winapi", "winapi-wsapoll", - "x11rb-protocol 0.12.0", -] - -[[package]] -name = "x11rb-protocol" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56b245751c0ac9db0e006dc812031482784e434630205a93c73cfefcaabeac67" -dependencies = [ - "nix 0.24.3", + "x11rb-protocol", ] [[package]] @@ -3046,7 +2993,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d6c3f9a0fb6701fab8f6cea9b0c0bd5d6876f1f89f7fada07e558077c344bc" dependencies = [ - "nix 0.26.4", + "nix", ] [[package]] diff --git a/examples/focus.rs b/examples/focus.rs new file mode 100644 index 0000000..f9ab395 --- /dev/null +++ b/examples/focus.rs @@ -0,0 +1,35 @@ +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::input::InputValue; +use gooey::widgets::slider::Slidable; +use gooey::widgets::{Checkbox, Custom}; +use gooey::Run; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + let allow_blur = Dynamic::new(true); + "Input Field" + .and(Dynamic::::default().into_input()) + .and("Range Slider") + .and(Dynamic::::default().slider_between(0_u8, 100_u8)) + .and("Range Slider") + .and(Dynamic::new(10..=30).slider_between(0_u8, 100_u8)) + .and(Checkbox::new( + allow_blur.clone(), + "Allow Custom Widget to Lose Focus", + )) + .and( + Custom::empty() + .on_accept_focus(|_| true) + .on_redraw(|context| { + context.fill(context.theme().secondary.color); + if context.focused() { + context.draw_focus_ring(); + } + }) + .on_allow_blur(move |_| allow_blur.get()) + .height(Lp::inches(1)), + ) + .into_rows() + .run() +} diff --git a/examples/slider.rs b/examples/slider.rs index 08ba3b8..03dc203 100644 --- a/examples/slider.rs +++ b/examples/slider.rs @@ -1,5 +1,5 @@ use gooey::animation::{LinearInterpolate, PercentBetween}; -use gooey::value::Dynamic; +use gooey::value::{Dynamic, ForEach}; use gooey::widget::MakeWidget; use gooey::widgets::input::InputValue; use gooey::widgets::slider::Slidable; @@ -9,9 +9,11 @@ use kludgine::figures::Ranged; fn main() -> gooey::Result { u8_slider() + .and(u8_range_slider()) .and(enum_slider()) .into_rows() .expand_horizontally() + .contain() .width(..Lp::points(800)) .centered() .expand() @@ -19,10 +21,10 @@ fn main() -> gooey::Result { } fn u8_slider() -> impl MakeWidget { - let min_text = Dynamic::new(u8::MIN.to_string()); - let min = min_text.map_each(|min| min.parse().unwrap_or(u8::MIN)); - let max_text = Dynamic::new(u8::MAX.to_string()); - let max = max_text.map_each(|max| max.parse().unwrap_or(u8::MAX)); + let min = Dynamic::new(u8::MIN); + let min_text = min.linked_string(); + let max = Dynamic::new(u8::MAX); + let max_text = max.linked_string(); let value = Dynamic::new(128_u8); let value_text = value.map_each(ToString::to_string); @@ -37,6 +39,40 @@ fn u8_slider() -> impl MakeWidget { .into_rows() } +fn u8_range_slider() -> impl MakeWidget { + let range = Dynamic::new(42..=127); + let start = range.map_each_unique(|range| *range.start()); + let end = range.map_each_unique(|range| *range.end()); + (&start, &end).for_each({ + let range = range.clone(); + move |(start, end)| { + let _result = range.try_update(*start..=*end); + } + }); + + let min = Dynamic::new(u8::MIN); + let min_text = min.linked_string(); + let start_text = start.linked_string(); + let end_text = end.linked_string(); + let max = Dynamic::new(u8::MAX); + let max_text = max.linked_string(); + let value_text = range.map_each(|r| format!("{}..={}", r.start(), r.end())); + + "Min" + .and(min_text.into_input()) + .and("Start") + .and(start_text.into_input()) + .and("End") + .and(end_text.into_input()) + .and("Max") + .and(max_text.into_input()) + .into_columns() + .centered() + .and(range.slider_between(min, max)) + .and(value_text.centered()) + .into_rows() +} + #[derive(LinearInterpolate, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] enum SlidableEnum { A, diff --git a/src/context.rs b/src/context.rs index c1a672c..5c7e907 100644 --- a/src/context.rs +++ b/src/context.rs @@ -178,6 +178,7 @@ impl<'context, 'window> EventContext<'context, 'window> { } } + #[allow(clippy::too_many_lines)] // TODO pub(crate) fn apply_pending_state(&mut self) { const MAX_ITERS: u8 = 100; // These two blocks apply active/focus in a loop to pick up the event @@ -262,9 +263,18 @@ impl<'context, 'window> EventContext<'context, 'window> { }); let new = match self.current_node.tree.focus(self.pending_state.focus) { Ok(old) => { - if let Some(old) = old { - let mut old_context = self.for_other(&old); - old.lock().as_widget().blur(&mut old_context); + if let Some(old_widget) = old { + let mut old_context = self.for_other(&old_widget); + let mut old = old_widget.lock(); + if old.as_widget().allow_blur(&mut old_context) { + old.as_widget().blur(&mut old_context); + } else { + // This widget is rejecting the focus change. + drop(old_context); + let _result = self.current_node.tree.focus(Some(old_widget.id())); + self.pending_state.focus = Some(old_widget.id()); + break; + } } true } @@ -417,6 +427,20 @@ impl<'context, 'window> EventContext<'context, 'window> { } fn move_focus(&mut self, advance: bool) { + let node = self.current_node.clone(); + let mut direction = self.get(&LayoutOrder); + if !advance { + direction = direction.rev(); + } + if node + .lock() + .as_widget() + .advance_focus(direction, self) + .is_break() + { + return; + } + if let Some(explicit_next_focus) = self.current_node.explicit_focus_target(advance) { self.for_other(&explicit_next_focus).focus(); } else { @@ -951,6 +975,14 @@ impl<'context, 'window> WidgetContext<'context, 'window> { } } + /// Returns true if the last focus event was an advancing motion, not a + /// returning motion. + /// + /// This value is meaningless outside of focus-related events. + pub fn focus_is_advancing(&mut self) -> bool { + self.pending_state.focus_is_advancing + } + /// Activates this widget, if it is not already active. /// /// Returns true if this function resulted in the currently active widget diff --git a/src/widget.rs b/src/widget.rs index 5c74c6b..11dcd40 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -51,12 +51,6 @@ pub trait Widget: Send + UnwindSafe + Debug + 'static { available_space.map(ConstraintLimit::min) } - /// Return true if this widget should expand to fill the window when it is - /// the root widget. - fn expand_if_at_root(&self) -> Option { - Some(false) - } - /// The widget has been mounted into a parent widget. #[allow(unused_variables)] fn mounted(&mut self, context: &mut EventContext<'_, '_>) {} @@ -91,6 +85,25 @@ pub trait Widget: Send + UnwindSafe + Debug + 'static { #[allow(unused_variables)] fn focus(&mut self, context: &mut EventContext<'_, '_>) {} + /// The widget should switch to the next focusable area within this widget, + /// honoring `direction` in a consistent manner. Returning `HANDLED` will + /// cause the search for the next focus widget stop. + #[allow(unused_variables)] + fn advance_focus( + &mut self, + direction: VisualOrder, + context: &mut EventContext<'_, '_>, + ) -> EventHandling { + IGNORED + } + + /// The widget is about to lose focus. Returning true allows the focus to + /// switch away from this widget. + #[allow(unused_variables)] + fn allow_blur(&mut self, context: &mut EventContext<'_, '_>) -> bool { + true + } + /// The widget is no longer focused for user input. #[allow(unused_variables)] fn blur(&mut self, context: &mut EventContext<'_, '_>) {} @@ -333,10 +346,29 @@ pub trait WrapperWidget: Debug + Send + UnwindSafe + 'static { false } + /// The widget should switch to the next focusable area within this widget, + /// honoring `direction` in a consistent manner. Returning `HANDLED` will + /// cause the search for the next focus widget stop. + #[allow(unused_variables)] + fn advance_focus( + &mut self, + direction: VisualOrder, + context: &mut EventContext<'_, '_>, + ) -> EventHandling { + IGNORED + } + /// The widget has received focus for user input. #[allow(unused_variables)] fn focus(&mut self, context: &mut EventContext<'_, '_>) {} + /// The widget is about to lose focus. Returning true allows the focus to + /// switch away from this widget. + #[allow(unused_variables)] + fn allow_blur(&mut self, context: &mut EventContext<'_, '_>) -> bool { + true + } + /// The widget is no longer focused for user input. #[allow(unused_variables)] fn blur(&mut self, context: &mut EventContext<'_, '_>) {} @@ -548,6 +580,18 @@ where ) -> EventHandling { T::mouse_wheel(self, device_id, delta, phase, context) } + + fn advance_focus( + &mut self, + direction: VisualOrder, + context: &mut EventContext<'_, '_>, + ) -> EventHandling { + T::advance_focus(self, direction, context) + } + + fn allow_blur(&mut self, context: &mut EventContext<'_, '_>) -> bool { + T::allow_blur(self, context) + } } /// A type that can create a [`WidgetInstance`]. diff --git a/src/widgets/custom.rs b/src/widgets/custom.rs index baf5057..3107f52 100644 --- a/src/widgets/custom.rs +++ b/src/widgets/custom.rs @@ -9,6 +9,7 @@ use kludgine::figures::{Point, Size}; use kludgine::Color; use crate::context::{EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::styles::VisualOrder; use crate::value::{IntoValue, Value}; use crate::widget::{EventHandling, MakeWidget, WidgetRef, WrappedLayout, WrapperWidget, IGNORED}; use crate::widgets::Space; @@ -44,6 +45,8 @@ pub struct Custom { keyboard_input: Option>>, mouse_wheel: Option>>, + allow_blur: Option>>, + advance_focus: Option>>, } impl Debug for Custom { @@ -91,6 +94,8 @@ impl Custom { ime: None, keyboard_input: None, mouse_wheel: None, + allow_blur: None, + advance_focus: None, } } @@ -262,6 +267,42 @@ impl Custom { self } + /// Invokes `allow_blur` when this widget is about to lose focus. If + /// `allow_blur` returns false, focus will be disallowed from leaving this + /// widget. + /// + /// This callback corresponds to [`WrapperWidget::allow_blur`]. + pub fn on_allow_blur(mut self, allow_blur: AllowBlur) -> Self + where + AllowBlur: Send + + UnwindSafe + + 'static + + for<'context, 'window> FnMut(&mut EventContext<'context, 'window>) -> bool, + { + self.allow_blur = Some(Box::new(allow_blur)); + self + } + + /// Invokes `advance_focus` when this widget has focus and focus is + /// requested to advance to another widget. Returning + /// [`HANDLED`](crate::widget::HANDLED) will signal to Gooey that the focus + /// has moved and the request should not be processed any further. + /// + /// This callback corresponds to [`WrapperWidget::advance_focus`]. + pub fn on_advance_focus(mut self, advance_focus: AdvanceFocus) -> Self + where + AdvanceFocus: Send + + UnwindSafe + + 'static + + for<'context, 'window> FnMut( + VisualOrder, + &mut EventContext<'context, 'window>, + ) -> EventHandling, + { + self.advance_focus = Some(Box::new(advance_focus)); + self + } + /// Invokes `adjust_child_constraints` before measuring the child widget. /// The returned constraints will be passed along to the child in its layout /// function. @@ -639,6 +680,26 @@ impl WrapperWidget for Custom { IGNORED } } + + fn advance_focus( + &mut self, + direction: VisualOrder, + context: &mut EventContext<'_, '_>, + ) -> EventHandling { + if let Some(advance_focus) = &mut self.advance_focus { + advance_focus.invoke(direction, context) + } else { + IGNORED + } + } + + fn allow_blur(&mut self, context: &mut EventContext<'_, '_>) -> bool { + if let Some(allow_blur) = &mut self.allow_blur { + allow_blur.invoke(context) + } else { + true + } + } } trait RedrawFunc: Send + UnwindSafe { diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index 93617e4..6ed7bae 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -1,140 +1,273 @@ //! A widget that allows a user to "slide" between values. use std::fmt::Debug; +use std::mem; +use std::ops::RangeInclusive; use std::panic::UnwindSafe; -use kludgine::app::winit::event::{DeviceId, MouseButton}; +use intentional::{Assert, Cast as _}; +use kludgine::app::winit::event::{DeviceId, MouseButton, MouseScrollDelta, TouchPhase}; +use kludgine::app::winit::keyboard::{Key, NamedKey}; use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{ - FloatConversion, FromComponents, IntoComponents, IntoSigned, Point, Ranged, Rect, ScreenScale, - Size, + FloatConversion, FromComponents, IntoComponents, IntoSigned, Point, Ranged, Rect, Round, + ScreenScale, Size, }; -use kludgine::shapes::Shape; +use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::{Color, DrawableExt, Origin}; -use crate::animation::{LinearInterpolate, PercentBetween}; +use crate::animation::{LinearInterpolate, PercentBetween, ZeroToOne}; use crate::context::{EventContext, GraphicsContext, LayoutContext}; -use crate::styles::components::{OpaqueWidgetColor, WidgetAccentColor}; -use crate::styles::Dimension; +use crate::styles::components::{ + AutoFocusableControls, OpaqueWidgetColor, OutlineColor, WidgetAccentColor, +}; +use crate::styles::{Dimension, HorizontalOrder, VerticalOrder, VisualOrder}; use crate::value::{Dynamic, IntoDynamic, IntoValue, Value}; -use crate::widget::{EventHandling, Widget, HANDLED}; +use crate::widget::{EventHandling, Widget, HANDLED, IGNORED}; use crate::ConstraintLimit; /// A widget that allows sliding between two values. #[derive(Debug, Clone)] -pub struct Slider { +pub struct Slider +where + T: SliderValue, +{ /// The current value. pub value: Dynamic, /// The minimum value represented by this slider. - pub minimum: Value, + pub minimum: Value, /// The maximum value represented by this slider. - pub maximum: Value, + pub maximum: Value, + /// The percentage to step when advancing the slider using alternative + /// inputs (e.g, keyboard/mousewheel). + /// + /// The widget will use this as a starting value, but will continue to step + /// by this amount until a new unique value is obtained from linear + /// interpolation. + /// + /// This defaults to `0.05`/5%. + pub step: Value, knob_size: UPx, horizontal: bool, rendered_size: Px, + focused_knob: Option, + previous_focus: Option, + mouse_buttons_down: usize, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum Knob { + Start, + End, } impl Slider where - T: Ranged, + T: SliderValue, + T::Value: 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) + Self::new(value, ::MIN, ::MAX) } } -impl Slider { +impl Slider +where + T: SliderValue, +{ /// 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 { + 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(), + step: Value::Constant(ZeroToOne::new(0.05)), knob_size: UPx::ZERO, horizontal: true, rendered_size: Px::ZERO, + focused_knob: None, + mouse_buttons_down: 0, + previous_focus: None, } } /// Sets the maximum value of this slider to `max` and returns self. #[must_use] - pub fn maximum(mut self, max: impl IntoValue) -> Self { + 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 { + pub fn minimum(mut self, min: impl IntoValue) -> Self { self.minimum = min.into_value(); self } + /// The percentage to step when advancing the slider using alternative + /// inputs (e.g, keyboard/mousewheel). + /// + /// The widget will use this as a starting value, but will continue to step + /// by this amount until a new unique value is obtained from linear + /// interpolation. + /// + /// This defaults to `0.05`/5%. + #[must_use] + pub fn step_by(mut self, percent: impl IntoValue) -> Self { + self.step = percent.into_value(); + 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 track_length = self.rendered_size - spec.knob_size; - let value_location = track_length * spec.percent; + let half_focus_ring = (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 (start, end) = if let Some(end) = spec.end { + (track_length * spec.start, track_length * end) + } else { + (Px::ZERO, track_length * spec.start) + }; + let inset = Point::squared(half_focus_ring); let half_track = spec.track_size / 2; // Draw the track - if value_location < track_length { - context.gfx.draw_shape(&Shape::filled_round_rect( - Rect::new( - flipped( - !self.horizontal, - Point::new(value_location + spec.half_knob, spec.half_knob - half_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, Size::new(start, spec.track_size)), ), - flipped( - !self.horizontal, - Size::new(track_length - value_location + half_track, spec.track_size), + half_track, + spec.inactive_track_color, + ) + .translate_by(inset), + ); + } + if end < track_length { + context.gfx.draw_shape( + Shape::filled_round_rect( + Rect::new( + flipped( + !self.horizontal, + Point::new(end + spec.half_knob, spec.half_knob - half_track), + ), + flipped( + !self.horizontal, + Size::new(track_length - end + half_track, spec.track_size), + ), ), - ), - half_track, - spec.inactive_track_color, - )); + half_track, + spec.inactive_track_color, + ) + .translate_by(inset), + ); } - if value_location > 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), + if start != end { + context.gfx.draw_shape( + Shape::filled_round_rect( + Rect::new( + flipped( + !self.horizontal, + Point::new( + start + spec.half_knob - half_track, + spec.half_knob - half_track, + ), + ), + flipped( + !self.horizontal, + Size::new(end - start + spec.track_size, spec.track_size), + ), ), - flipped( - !self.horizontal, - Size::new(value_location + spec.track_size, spec.track_size), - ), - ), - half_track, - spec.track_color, - )); + half_track, + spec.track_color, + ) + .translate_by(inset), + ); } // Draw the knob - context.gfx.draw_shape( - Shape::filled_circle(spec.half_knob, spec.knob_color, Origin::Center).translate_by( + 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( flipped( !self.horizontal, - Point::new(value_location + spec.half_knob, spec.half_knob), + Point::new(start + spec.half_knob, spec.half_knob) + inset, ), - ), + focused && matches!(self.focused_knob, Some(Knob::Start)), + focus_ring, + spec, + context, + ); + } + } + + fn draw_knob( + &mut self, + knob_center: Point, + is_focused: bool, + focus_ring_width: Px, + spec: &TrackSpec, + context: &mut GraphicsContext<'_, '_, '_, '_, '_>, + ) { + context.gfx.draw_shape( + Shape::filled_circle(spec.half_knob, spec.knob_color, Origin::Center) + .translate_by(flipped(!self.horizontal, knob_center)), ); + + if is_focused { + let focus_color = context.get(&OutlineColor); + context.gfx.draw_shape( + Shape::stroked_circle( + spec.half_knob, + focus_color, + Origin::Center, + StrokeOptions::px_wide(focus_ring_width), + ) + .translate_by(knob_center), + ); + } } } impl Slider where - T: LinearInterpolate + Clone, + T: SliderValue, { - fn update_from_click(&mut self, position: Point) { + fn update_from_click(&mut self, position: Point, previous_focus: Option) { let knob_size = self.knob_size.into_signed(); let position = if self.horizontal { position.x - knob_size / 2 @@ -144,22 +277,113 @@ where let track_width = self.rendered_size - knob_size; let position = position.clamp(Px::ZERO, track_width); let percent = position.into_float() / track_width.into_float(); + let min = self.minimum.get(); let max = self.maximum.get(); - self.value.update(min.lerp(&max, percent)); + let value = min.lerp(&max, percent); + let (mut start, mut opt_end) = T::into_parts(self.value.get()); + if let Some(end) = &opt_end { + let knob = if let Some(knob) = self.focused_knob { + knob + } else { + // Check if the click is overlapping either knob + let start_percent = start.percent_between(&min, &max); + 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)) + { + 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 + } + }; + match knob { + Knob::Start => { + if &value <= end { + start = value; + } else { + start = end.clone(); + } + } + Knob::End => { + if value >= start { + opt_end = Some(value); + } else { + opt_end = Some(start.clone()); + } + } + } + self.focused_knob = Some(knob); + } else { + start = value; + self.focused_knob = Some(Knob::Start); + } + self.value.update(T::from_parts(start, opt_end)); + } + + fn step(&mut self, forwards: bool, factor: f32) { + let Some(focus) = self + .focused_knob + .or_else(|| (!T::RANGED).then_some(Knob::Start)) + else { + return; + }; + let (current, other) = match (focus, T::into_parts(self.value.get())) { + (Knob::Start, (current, other)) => (current, other), + (Knob::End, (other, Some(current))) => (current, Some(other)), + (Knob::End, (_, None)) => unreachable!("invalid state"), + }; + let min = self.minimum.get(); + let max = self.maximum.get(); + let step = self.step.get(); + let mut current_percent = current.percent_between(&min, &max); + let new_value = loop { + let next = if forwards { + *current_percent + *step * factor + } else { + *current_percent - *step * factor + }; + if next < 0. { + break min.clone(); + } else if next > 1. { + break max.clone(); + } + current_percent = ZeroToOne::new(next); + let generated_value = min.lerp(&max, *current_percent); + if generated_value != current { + break generated_value; + } + }; + // Check that the new value didn't go past the other marker, or min/max. + let valid_relative_to_other = match (&other, focus) { + (Some(end), Knob::Start) => new_value < *end, + (Some(start), Knob::End) => new_value > *start, + (None, _) => true, + }; + if valid_relative_to_other && new_value >= min && new_value <= max { + let (start, end) = match (focus, other) { + (_, None) => (new_value, None), + (Knob::Start, Some(end)) => (new_value, Some(end)), + (Knob::End, Some(start)) => (start, Some(new_value)), + }; + self.value.update(T::from_parts(start, end)); + } } } impl Widget for Slider where - T: Clone - + Debug - + PartialOrd - + LinearInterpolate - + PercentBetween - + UnwindSafe - + Send - + 'static, + T: SliderValue, { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { let track_color = context.get(&TrackColor); @@ -173,7 +397,8 @@ where let half_knob = knob_size / 2; - let mut value = self.value.get_tracking_refresh(context); + let (mut start_value, mut end_value) = + T::into_parts(self.value.get_tracking_refresh(context)); let min = self.minimum.get_tracked(context); let mut max = self.maximum.get_tracked(context); @@ -182,19 +407,34 @@ where max = min.clone(); } let mut value_clamped = false; - if value < min { + if start_value < min { value_clamped = true; - value = min.clone(); - } else if value > max { + start_value = min.clone(); + } else if start_value > max { value_clamped = true; - value = max.clone(); + start_value = max.clone(); + } + + if let Some(end) = &mut end_value { + if *end < min { + value_clamped = true; + *end = min.clone(); + } else if *end < start_value { + value_clamped = true; + mem::swap(&mut start_value, end); + } else if *end > max { + value_clamped = true; + *end = max.clone(); + } } if value_clamped { - self.value.map_mut(|v| *v = value.clone()); + self.value + .map_mut(|v| *v = T::from_parts(start_value.clone(), end_value.clone())); } - let percent = value.percent_between(&min, &max); + let start_percent = start_value.percent_between(&min, &max); + let end_percent = end_value.map(|end| *end.percent_between(&min, &max)); let size = context.gfx.region().size; self.horizontal = size.width >= size.height; @@ -202,7 +442,8 @@ where self.draw_track( &TrackSpec { size, - percent: *percent, + start: *start_percent, + end: end_percent, half_knob, knob_size, track_size, @@ -223,6 +464,8 @@ where 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; match (available_space.width, available_space.height) { (ConstraintLimit::Fill(width), ConstraintLimit::Fill(height)) => { @@ -230,17 +473,17 @@ where // up with a horizontal slider. if width < height { // Vertical slider - Size::new(self.knob_size, height.max(minimum_size)) + Size::new(focused_knob_size, height.max(minimum_size)) } else { // Horizontal slider - Size::new(width.max(minimum_size), self.knob_size) + Size::new(width.max(minimum_size), focused_knob_size) } } (ConstraintLimit::Fill(width), ConstraintLimit::SizeToFit(_)) => { - Size::new(width.max(minimum_size), self.knob_size) + Size::new(width.max(minimum_size), focused_knob_size) } (ConstraintLimit::SizeToFit(_), ConstraintLimit::Fill(height)) => { - Size::new(self.knob_size, height.max(minimum_size)) + Size::new(focused_knob_size, height.max(minimum_size)) } (ConstraintLimit::SizeToFit(width), ConstraintLimit::SizeToFit(_)) => { // When we have no limit on our, we still want to be draggable. @@ -250,7 +493,7 @@ 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), self.knob_size) + Size::new(width.min(minimum_size), focused_knob_size) } } } @@ -259,14 +502,67 @@ where true } + fn accept_focus(&mut self, context: &mut EventContext<'_, '_>) -> bool { + context.get(&AutoFocusableControls).is_all() + } + + fn focus(&mut self, context: &mut EventContext<'_, '_>) { + if self.mouse_buttons_down == 0 { + self.focused_knob = Some(if T::RANGED && !context.focus_is_advancing() { + Knob::End + } else { + Knob::Start + }); + context.set_needs_redraw(); + } + } + + fn advance_focus( + &mut self, + direction: VisualOrder, + context: &mut EventContext<'_, '_>, + ) -> EventHandling { + let (true, Some(focused)) = (T::RANGED, self.focused_knob) else { + return IGNORED; + }; + + let new_knob = if self.horizontal { + match (direction.horizontal, focused) { + (HorizontalOrder::LeftToRight, Knob::Start) => Knob::End, + (HorizontalOrder::RightToLeft, Knob::End) => Knob::Start, + _ => return IGNORED, + } + } else { + match (direction.vertical, focused) { + (VerticalOrder::TopToBottom, Knob::Start) => Knob::End, + (VerticalOrder::BottomToTop, Knob::End) => Knob::Start, + _ => return IGNORED, + } + }; + self.focused_knob = Some(new_knob); + context.set_needs_redraw(); + HANDLED + } + + fn blur(&mut self, context: &mut EventContext<'_, '_>) { + self.previous_focus = self.focused_knob.take(); + context.set_needs_redraw(); + } + fn mouse_down( &mut self, location: Point, _device_id: DeviceId, _button: MouseButton, - _context: &mut EventContext<'_, '_>, + context: &mut EventContext<'_, '_>, ) -> EventHandling { - self.update_from_click(location); + let previous_focus = match (self.previous_focus.take(), self.focused_knob.take()) { + (None | Some(_), Some(focus)) | (Some(focus), None) => Some(focus), + (None, None) => None, + }; + self.update_from_click(location, previous_focus); + self.mouse_buttons_down += 1; + context.focus(); HANDLED } @@ -277,13 +573,70 @@ where _button: MouseButton, _context: &mut EventContext<'_, '_>, ) { - self.update_from_click(location); + self.update_from_click(location, None); + } + + fn mouse_up( + &mut self, + _location: Option>, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_, '_>, + ) { + self.mouse_buttons_down -= 1; + } + + fn keyboard_input( + &mut self, + _device_id: DeviceId, + input: kludgine::app::winit::event::KeyEvent, + _is_synthetic: bool, + _context: &mut EventContext<'_, '_>, + ) -> EventHandling { + let forwards = match input.logical_key { + Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => false, + Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown) => true, + _ => return IGNORED, + }; + if !input.state.is_pressed() { + return HANDLED; + } + + self.step(forwards, 1.); + + HANDLED + } + + fn mouse_wheel( + &mut self, + _device_id: DeviceId, + delta: MouseScrollDelta, + _phase: TouchPhase, + _context: &mut EventContext<'_, '_>, + ) -> EventHandling { + let factor: f32 = match delta { + MouseScrollDelta::LineDelta(_, y) => y, + MouseScrollDelta::PixelDelta(pt) => pt.y.cast(), + }; + + let (forwards, factor) = if factor.is_sign_negative() { + (false, -factor) + } else { + (true, factor) + }; + + self.step(forwards, factor); + + // @ecton: Unlike scroll alreas cascasing, I feel like scrolling while + // using a mouse wheel as an input is annoying. + HANDLED } } struct TrackSpec { size: Size, - percent: f32, + start: f32, + end: Option, half_knob: Px, knob_size: Px, track_size: Px, @@ -322,7 +675,27 @@ define_components! { } /// A value that can be used in a [`Slider`] widget. -pub trait Slidable: IntoDynamic + Sized +pub trait SliderValue: Clone + PartialEq + UnwindSafe + Send + Debug + 'static { + /// The component value for the slider. + type Value: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static; + /// When true, this type is expected to represent two values: start and an + /// end. + const RANGED: bool; + + /// Returns this value split into its start and end components. + fn into_parts(self) -> (Self::Value, Option); + /// Constructs a value from its start and end components. + fn from_parts(min_or_value: Self::Value, max: Option) -> Self; +} + +impl SliderValue for T where T: Clone + Debug @@ -333,25 +706,22 @@ where + Send + 'static, { - /// Returns a new slider over the full [range](Ranged) of the type. - fn slider(self) -> Slider - where - T: Ranged, - { - Slider::from_value(self.into_dynamic()) + type Value = T; + + const RANGED: bool = false; + + fn into_parts(self) -> (Self::Value, Option) { + (self, None) } - /// Returns a new slider using the value of `self`. The slider will be - /// limited to values between `min` and `max`. - fn slider_between(self, min: impl IntoValue, max: impl IntoValue) -> Slider { - Slider::new(self.into_dynamic(), min, max) + fn from_parts(min_or_value: Self::Value, _max: Option) -> Self { + min_or_value } } -impl Slidable for T +impl SliderValue for RangeInclusive where - T: IntoDynamic, - U: Clone + T: Clone + Debug + PartialOrd + LinearInterpolate @@ -359,5 +729,72 @@ where + UnwindSafe + Send + 'static, +{ + type Value = T; + + const RANGED: bool = true; + + fn into_parts(self) -> (Self::Value, Option) { + let (start, end) = self.into_inner(); + (start, Some(end)) + } + + fn from_parts(min_or_value: Self::Value, max: Option) -> Self { + min_or_value..=max.assert("always provided") + } +} + +impl SliderValue for (T, T) +where + T: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ + type Value = T; + + const RANGED: bool = true; + + fn into_parts(self) -> (Self::Value, Option) { + (self.0, Some(self.1)) + } + + fn from_parts(min_or_value: Self::Value, max: Option) -> Self { + (min_or_value, max.assert("always provided")) + } +} + +/// A value that can be used in a [`Slider`] widget. +pub trait Slidable: IntoDynamic + Sized +where + T: SliderValue, +{ + /// Returns a new slider over the full [range](Ranged) of the type. + fn slider(self) -> Slider + where + T::Value: Ranged, + { + Slider::from_value(self.into_dynamic()) + } + + /// Returns a new slider using the value of `self`. The slider will be + /// limited to values between `min` and `max`. + fn slider_between( + self, + min: impl IntoValue, + max: impl IntoValue, + ) -> Slider { + Slider::new(self.into_dynamic(), min, max) + } +} + +impl Slidable for T +where + T: IntoDynamic, + U: SliderValue, { } diff --git a/src/window.rs b/src/window.rs index a67e7a8..4a52625 100644 --- a/src/window.rs +++ b/src/window.rs @@ -819,6 +819,7 @@ where ), kludgine, ); + if reverse { target.return_focus(); } else {