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.
This commit is contained in:
Jonathan Johnson 2023-11-20 19:44:03 -08:00
parent 8f2ff1b5dc
commit 2201f2c83b
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
8 changed files with 769 additions and 176 deletions

87
Cargo.lock generated
View file

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

35
examples/focus.rs Normal file
View file

@ -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::<String>::default().into_input())
.and("Range Slider")
.and(Dynamic::<u8>::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()
}

View file

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

View file

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

View file

@ -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<bool> {
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`].

View file

@ -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<Box<dyn ThreeParamEventFunc<DeviceId, KeyEvent, bool, EventHandling>>>,
mouse_wheel:
Option<Box<dyn ThreeParamEventFunc<DeviceId, MouseScrollDelta, TouchPhase, EventHandling>>>,
allow_blur: Option<Box<dyn EventFunc<bool>>>,
advance_focus: Option<Box<dyn OneParamEventFunc<VisualOrder, EventHandling>>>,
}
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<AllowBlur>(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<AdvanceFocus>(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 {

View file

@ -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<T> {
pub struct Slider<T>
where
T: SliderValue,
{
/// The current value.
pub value: Dynamic<T>,
/// The minimum value represented by this slider.
pub minimum: Value<T>,
pub minimum: Value<T::Value>,
/// The maximum value represented by this slider.
pub maximum: Value<T>,
pub maximum: Value<T::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<ZeroToOne>,
knob_size: UPx,
horizontal: bool,
rendered_size: Px,
focused_knob: Option<Knob>,
previous_focus: Option<Knob>,
mouse_buttons_down: usize,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum Knob {
Start,
End,
}
impl<T> Slider<T>
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<T>) -> Self {
Self::new(value, T::MIN, T::MAX)
Self::new(value, <T::Value>::MIN, <T::Value>::MAX)
}
}
impl<T> Slider<T> {
impl<T> Slider<T>
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<T>, min: impl IntoValue<T>, max: impl IntoValue<T>) -> Self {
pub fn new(
value: impl IntoDynamic<T>,
min: impl IntoValue<T::Value>,
max: impl IntoValue<T::Value>,
) -> 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<T>) -> Self {
pub fn maximum(mut self, max: impl IntoValue<T::Value>) -> 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<T>) -> Self {
pub fn minimum(mut self, min: impl IntoValue<T::Value>) -> 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<ZeroToOne>) -> 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<Px>,
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<T> Slider<T>
where
T: LinearInterpolate + Clone,
T: SliderValue,
{
fn update_from_click(&mut self, position: Point<Px>) {
fn update_from_click(&mut self, position: Point<Px>, previous_focus: Option<Knob>) {
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<T> Widget for Slider<T>
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<Px>,
_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<Point<Px>>,
_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<Px>,
percent: f32,
start: f32,
end: Option<f32>,
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<T>: IntoDynamic<T> + 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<Self::Value>);
/// Constructs a value from its start and end components.
fn from_parts(min_or_value: Self::Value, max: Option<Self::Value>) -> Self;
}
impl<T> 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<T>
where
T: Ranged,
{
Slider::from_value(self.into_dynamic())
type Value = T;
const RANGED: bool = false;
fn into_parts(self) -> (Self::Value, Option<Self::Value>) {
(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<T>, max: impl IntoValue<T>) -> Slider<T> {
Slider::new(self.into_dynamic(), min, max)
fn from_parts(min_or_value: Self::Value, _max: Option<Self::Value>) -> Self {
min_or_value
}
}
impl<U, T> Slidable<U> for T
impl<T> SliderValue for RangeInclusive<T>
where
T: IntoDynamic<U>,
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<Self::Value>) {
let (start, end) = self.into_inner();
(start, Some(end))
}
fn from_parts(min_or_value: Self::Value, max: Option<Self::Value>) -> Self {
min_or_value..=max.assert("always provided")
}
}
impl<T> 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::Value>) {
(self.0, Some(self.1))
}
fn from_parts(min_or_value: Self::Value, max: Option<Self::Value>) -> Self {
(min_or_value, max.assert("always provided"))
}
}
/// A value that can be used in a [`Slider`] widget.
pub trait Slidable<T>: IntoDynamic<T> + Sized
where
T: SliderValue,
{
/// Returns a new slider over the full [range](Ranged) of the type.
fn slider(self) -> Slider<T>
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<T::Value>,
max: impl IntoValue<T::Value>,
) -> Slider<T> {
Slider::new(self.into_dynamic(), min, max)
}
}
impl<U, T> Slidable<U> for T
where
T: IntoDynamic<U>,
U: SliderValue,
{
}

View file

@ -819,6 +819,7 @@ where
),
kludgine,
);
if reverse {
target.return_focus();
} else {