diff --git a/CHANGELOG.md b/CHANGELOG.md index 794bd6f79..af30edcff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* Properties `size` and related now have a default value allowing conditional assign. +* Add `zng::slider` with `Slider` widget. * Fix `force_size` returning the parent's constraint min size. * Fix hit-test in rounded rectangles with too large corner radius. * Fix headless rendering in Wayland. Property `needs_fallback_chrome` now is `false` for headless windows. diff --git a/crates/README.md b/crates/README.md index dc3fd2e97..8e9433870 100644 --- a/crates/README.md +++ b/crates/README.md @@ -115,6 +115,7 @@ or for custom properties that deeply integrate with a widget. - `zng-wgt-tooltip` - `zng-wgt-markdown` - `zng-wgt-progress` +- `zng-wgt-slider` - `zng-wgt-material-icons` - `zng-wgt-webrender-debug` diff --git a/crates/zng-wgt-size-offset/src/lib.rs b/crates/zng-wgt-size-offset/src/lib.rs index 78d952343..57b233df0 100644 --- a/crates/zng-wgt-size-offset/src/lib.rs +++ b/crates/zng-wgt-size-offset/src/lib.rs @@ -343,7 +343,7 @@ pub fn max_height(child: impl UiNode, max_height: impl IntoVar) -> impl /// [`height`]: fn@height /// [`force_size`]: fn@force_size /// [`align`]: fn@zng_wgt::align -#[property(SIZE)] +#[property(SIZE, default(Size::default()))] pub fn size(child: impl UiNode, size: impl IntoVar) -> impl UiNode { let size = size.into_var(); match_node(child, move |child, op| match op { @@ -399,7 +399,7 @@ pub fn size(child: impl UiNode, size: impl IntoVar) -> impl UiNode { /// [`min_width`]: fn@min_width /// [`max_width`]: fn@max_width /// [`force_width`]: fn@force_width -#[property(SIZE)] +#[property(SIZE, default(Length::Default))] pub fn width(child: impl UiNode, width: impl IntoVar) -> impl UiNode { let width = width.into_var(); match_node(child, move |child, op| match op { @@ -454,7 +454,7 @@ pub fn width(child: impl UiNode, width: impl IntoVar) -> impl UiNode { /// [`min_height`]: fn@min_height /// [`max_height`]: fn@max_height /// [`force_height`]: fn@force_height -#[property(SIZE)] +#[property(SIZE, default(Length::Default))] pub fn height(child: impl UiNode, height: impl IntoVar) -> impl UiNode { let height = height.into_var(); match_node(child, move |child, op| match op { @@ -507,7 +507,7 @@ pub fn height(child: impl UiNode, height: impl IntoVar) -> impl UiNode { /// /// [`force_width`]: fn@force_width /// [`force_height`]: fn@force_height -#[property(SIZE)] +#[property(SIZE, default(Size::default()))] pub fn force_size(child: impl UiNode, size: impl IntoVar) -> impl UiNode { let size = size.into_var(); match_node(child, move |child, op| match op { @@ -551,7 +551,7 @@ pub fn force_size(child: impl UiNode, size: impl IntoVar) -> impl UiNode { /// # `force_size` /// /// You can set both `force_width` and `force_height` at the same time using the [`force_size`](fn@force_size) property. -#[property(SIZE)] +#[property(SIZE, default(Length::Default))] pub fn force_width(child: impl UiNode, width: impl IntoVar) -> impl UiNode { let width = width.into_var(); match_node(child, move |child, op| match op { @@ -598,7 +598,7 @@ pub fn force_width(child: impl UiNode, width: impl IntoVar) -> impl UiNo /// # `force_size` /// /// You can set both `force_width` and `force_height` at the same time using the [`force_size`](fn@force_size) property. -#[property(SIZE)] +#[property(SIZE, default(Length::Default))] pub fn force_height(child: impl UiNode, height: impl IntoVar) -> impl UiNode { let height = height.into_var(); match_node(child, move |child, op| match op { diff --git a/crates/zng-wgt-slider/Cargo.toml b/crates/zng-wgt-slider/Cargo.toml new file mode 100644 index 000000000..5cbc4f6f7 --- /dev/null +++ b/crates/zng-wgt-slider/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "zng-wgt-slider" +version = "0.1.0" +authors = ["The Zng Project Developers"] +edition = "2021" +license = "Apache-2.0 OR MIT" +readme = "README.md" +description = "Part of the zng project." +documentation = "https://zng-ui.github.io/doc/zng_wgt_slider" +repository = "https://github.com/zng-ui/zng" +categories = ["gui"] +keywords = ["gui", "ui", "user-interface", "zng"] + +[dependencies] +zng-var = { path = "../zng-var", version = "0.5.9" } +zng-wgt = { path = "../zng-wgt", version = "0.5.5" } +zng-wgt-style = { path = "../zng-wgt-style", version = "0.3.29" } +zng-wgt-input = { path = "../zng-wgt-input", version = "0.2.41" } +zng-ext-input = { path = "../zng-ext-input", version = "0.5.29" } +zng-wgt-container = { path = "../zng-wgt-container", version = "0.4.1" } +zng-wgt-size-offset = { path = "../zng-wgt-size-offset", version = "0.2.39" } +zng-wgt-fill = { path = "../zng-wgt-fill", version = "0.2.39" } +zng-wgt-transform = { path = "../zng-wgt-transform", version = "0.2.39" } + +parking_lot = "0.12" +tracing = "0.1" diff --git a/crates/zng-wgt-slider/README.md b/crates/zng-wgt-slider/README.md new file mode 100644 index 000000000..bf784cf50 --- /dev/null +++ b/crates/zng-wgt-slider/README.md @@ -0,0 +1,7 @@ + +This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-file#crates) project. + + + + + diff --git a/crates/zng-wgt-slider/src/lib.rs b/crates/zng-wgt-slider/src/lib.rs new file mode 100644 index 000000000..6ac8d75a6 --- /dev/null +++ b/crates/zng-wgt-slider/src/lib.rs @@ -0,0 +1,812 @@ +#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo-icon.png")] +#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo.png")] +//! +//! Widget for selecting a value or range by dragging a selector thumb. +//! +//! # Crate +//! +#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))] +#![warn(unused_extern_crates)] +#![warn(missing_docs)] + +zng_wgt::enable_widget_macros!(); + +pub mod thumb; + +use std::{any::Any, fmt, ops::Range, sync::Arc}; + +use colors::ACCENT_COLOR_VAR; +use parking_lot::Mutex; +use zng_var::{AnyVar, AnyVarValue, BoxedAnyVar}; +use zng_wgt::prelude::*; +use zng_wgt_input::{focus::FocusableMix, pointer_capture::capture_pointer}; +use zng_wgt_style::{impl_style_fn, style_fn, Style, StyleMix}; + +/// Value selector from a range of values. +#[widget($crate::Slider)] +pub struct Slider(FocusableMix>); +impl Slider { + fn widget_intrinsic(&mut self) { + self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn)); + + widget_set! { + self; + style_base_fn = style_fn!(|_| DefaultStyle!()); + capture_pointer = true; + } + } +} +impl_style_fn!(Slider); + +/// Default slider style. +#[widget($crate::DefaultStyle)] +pub struct DefaultStyle(Style); +impl DefaultStyle { + fn widget_intrinsic(&mut self) { + widget_set! { + self; + zng_wgt_container::child = SliderTrack! { + zng_wgt::corner_radius = 5; + zng_wgt_fill::background_color = ACCENT_COLOR_VAR.rgba(); + zng_wgt::margin = 8; // thumb overflow + + when #{SLIDER_DIRECTION_VAR}.is_horizontal() { + zng_wgt_size_offset::height = 5; + } + when #{SLIDER_DIRECTION_VAR}.is_vertical() { + zng_wgt_size_offset::width = 5; + } + }; + zng_wgt_container::child_align = Align::FILL_X; + } + } +} + +trait SelectorImpl: Send { + fn selection(&self) -> BoxedAnyVar; + fn set(&mut self, nearest: Factor, to: Factor); + fn thumbs(&self) -> Vec; + fn to_offset(&self, t: &dyn AnyVarValue) -> Option; + #[allow(clippy::wrong_self_convention)] + fn from_offset(&self, offset: Factor) -> Box; +} + +trait OffsetConvert: Send { + fn to(&self, t: &T) -> Factor; + fn from(&self, f: Factor) -> T; +} +impl Factor + Send, Ff: Fn(Factor) -> T + Send> OffsetConvert for (Tf, Ff) { + fn to(&self, t: &T) -> Factor { + (self.0)(t) + } + + fn from(&self, f: Factor) -> T { + (self.1)(f) + } +} + +/// Represents a type that can auto implement a [`Selector`]. +/// +/// # Implementing +/// +/// This trait is implemented for all primitive type and Zng layout types, if a type does not you +/// can declare custom conversions using [`Selector::value`]. +pub trait SelectorValue: VarValue { + /// Make the selector. + fn to_selector(value: BoxedVar, min: Self, max: Self) -> Selector; +} + +/// Defines the values and ranges selected by a slider. +/// +/// Selectors are set on the [`selector`](fn@selector) property. +#[derive(Clone)] +pub struct Selector(Arc>); +impl fmt::Debug for Selector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Selector(_)") + } +} +impl PartialEq for Selector { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} +impl Selector { + /// New with single value thumb of type `T` that can be set any value in the `min..=max` range. + pub fn value(selection: impl IntoVar, min: T, max: T) -> Self { + T::to_selector(selection.into_var().boxed(), min, max) + } + + /// New with a single value thumb of type `T`. + /// + /// The value must convert to a normalized factor `[0.fct()..=1.fct()]` where `0.fct()` is the minimum possible value and `1.fct()` is the maximum + /// possible value. If a value outside of this range is returned it is clamped to the range and the `selection` variable is updated back. + pub fn value_with( + selection: impl IntoVar, + to_offset: impl Fn(&T) -> Factor + Send + 'static, + from_offset: impl Fn(Factor) -> T + Send + 'static, + ) -> Self + where + T: VarValue, + { + struct SingleImpl { + selection: BoxedVar, + selection_fct: Factor, + to_from: Box>, + } + impl SelectorImpl for SingleImpl { + fn selection(&self) -> BoxedAnyVar { + self.selection.clone_any() + } + + fn set(&mut self, _: Factor, to: Factor) { + self.selection_fct = to; + let _ = self.selection.set(self.to_from.from(to)); + } + + fn thumbs(&self) -> Vec { + vec![ThumbValue { + offset: self.selection_fct, + n_of: (0, 0), + }] + } + + fn to_offset(&self, t: &dyn AnyVarValue) -> Option { + let f = self.to_from.to(t.as_any().downcast_ref::()?); + Some(f) + } + + fn from_offset(&self, offset: Factor) -> Box { + Box::new(self.to_from.from(offset)) + } + } + let selection = selection.into_var(); + Self(Arc::new(Mutex::new(SingleImpl { + selection_fct: selection.with(&to_offset), + selection: selection.boxed(), + to_from: Box::new((to_offset, from_offset)), + }))) + } + + /// New with two value thumbs of type `T` that can be set any value in the `min..=max` range. + pub fn range(range: impl IntoVar>, min: T, max: T) -> Self { + // create a selector just to get the conversion closures + let convert = T::to_selector(zng_var::LocalVar(min.clone()).boxed(), min, max); + Self::range_with(range.into_var(), clmv!(convert, |t| convert.to_offset(t).unwrap()), move |f| { + convert.from_offset(f).unwrap() + }) + } + + /// New with two values thumbs that define a range of type `T`. + /// + /// The conversion closure have the same constraints as [`value_with`]. + /// + /// [`value_with`]: Self::value_with + pub fn range_with( + range: impl IntoVar>, + to_offset: impl Fn(&T) -> Factor + Send + 'static, + from_offset: impl Fn(Factor) -> T + Send + 'static, + ) -> Self + where + T: VarValue, + { + struct RangeImpl { + selection: BoxedVar>, + selection_fct: [Factor; 2], + to_from: Box>, + } + impl SelectorImpl for RangeImpl { + fn selection(&self) -> BoxedAnyVar { + self.selection.clone_any() + } + + fn set(&mut self, nearest: Factor, to: Factor) { + if (self.selection_fct[0] - nearest).abs() < (self.selection_fct[1] - nearest).abs() { + self.selection_fct[0] = to; + } else { + self.selection_fct[1] = to; + } + if self.selection_fct[0] > self.selection_fct[1] { + self.selection_fct.swap(0, 1); + } + let start = self.to_from.from(self.selection_fct[0]); + let end = self.to_from.from(self.selection_fct[1]); + let _ = self.selection.set(start..end); + } + + fn thumbs(&self) -> Vec { + vec![ + ThumbValue { + offset: self.selection_fct[0], + n_of: (0, 2), + }, + ThumbValue { + offset: self.selection_fct[1], + n_of: (1, 2), + }, + ] + } + + fn to_offset(&self, t: &dyn AnyVarValue) -> Option { + let f = self.to_from.to(t.as_any().downcast_ref::()?); + Some(f) + } + + fn from_offset(&self, offset: Factor) -> Box { + Box::new(self.to_from.from(offset)) + } + } + let selection = range.into_var(); + + Self(Arc::new(Mutex::new(RangeImpl { + selection_fct: selection.with(|r| [to_offset(&r.start), to_offset(&r.end)]), + selection: selection.boxed(), + to_from: Box::new((to_offset, from_offset)), + }))) + } + + /// New with many value thumbs of type `T` that can be set any value in the `min..=max` range. + pub fn many(many: impl IntoVar>, min: T, max: T) -> Self { + // create a selector just to get the conversion closures + let convert = T::to_selector(zng_var::LocalVar(min.clone()).boxed(), min, max); + Self::many_with(many.into_var(), clmv!(convert, |t| convert.to_offset(t).unwrap()), move |f| { + convert.from_offset(f).unwrap() + }) + } + + /// New with many value thumbs of type `T`. + /// + /// The conversion closure have the same constraints as [`value_with`]. + /// + /// [`value_with`]: Self::value_with + pub fn many_with( + many: impl IntoVar>, + to_offset: impl Fn(&T) -> Factor + Send + 'static, + from_offset: impl Fn(Factor) -> T + Send + 'static, + ) -> Self + where + T: VarValue, + { + struct ManyImpl { + selection: BoxedVar>, + selection_fct: Vec, + to_from: Box>, + } + impl SelectorImpl for ManyImpl { + fn selection(&self) -> BoxedAnyVar { + self.selection.clone_any() + } + + fn set(&mut self, nearest: Factor, to: Factor) { + if let Some((i, _)) = self + .selection_fct + .iter() + .enumerate() + .map(|(i, &f)| (i, (f - nearest).abs())) + .reduce(|a, b| if a.1 < b.1 { a } else { b }) + { + self.selection_fct[i] = to; + self.selection_fct.sort_by(|a, b| a.0.total_cmp(&b.0)); + let s: Vec<_> = self.selection_fct.iter().map(|&f| self.to_from.from(f)).collect(); + let _ = self.selection.set(s); + } + } + + fn thumbs(&self) -> Vec { + let len = self.selection_fct.len().min(u16::MAX as usize) as u16; + self.selection_fct + .iter() + .enumerate() + .map(|(i, &f)| ThumbValue { + offset: f, + n_of: (i.min(u16::MAX as usize) as u16, len), + }) + .collect() + } + + fn to_offset(&self, t: &dyn AnyVarValue) -> Option { + let f = self.to_from.to(t.as_any().downcast_ref::()?); + Some(f) + } + + fn from_offset(&self, offset: Factor) -> Box { + Box::new(self.to_from.from(offset)) + } + } + let selection = many.into_var(); + Self(Arc::new(Mutex::new(ManyImpl { + selection_fct: selection.with(|m| m.iter().map(&to_offset).collect()), + selection: selection.boxed(), + to_from: Box::new((to_offset, from_offset)), + }))) + } + + /// New with no value thumb. + pub fn nil() -> Self { + Self::many_with(vec![], |_: &bool| 0.fct(), |_| false) + } + + /// Convert the value to a normalized factor. + /// + /// If `T` is not the same type returns `None`. + pub fn to_offset(&self, t: &T) -> Option { + self.0.lock().to_offset(t) + } + + /// Convert the normalized factor to a value `T`. + /// + /// If `T` is not the same type returns `None`. + pub fn from_offset(&self, offset: impl IntoValue) -> Option { + let b = self.0.lock().from_offset(offset.into()).downcast().ok()?; + Some(*b) + } + + /// Gets the value thumbs. + pub fn thumbs(&self) -> Vec { + self.0.lock().thumbs() + } + + /// Move the `nearest_thumb` to a new offset. + /// + /// Note that ranges don't invert, this operation may swap the thumb roles. + pub fn set(&self, nearest_thumb: impl IntoValue, to: impl IntoValue) { + self.0.lock().set(nearest_thumb.into(), to.into()) + } + + /// The selection var. + /// + /// Downcast to `T` or `Range` to get and set the value. + pub fn selection(&self) -> BoxedAnyVar { + self.0.lock().selection() + } +} + +/// Represents a selector thumb in a slider. +#[derive(Clone, Debug, PartialEq, Copy)] +pub struct ThumbValue { + offset: Factor, + n_of: (u16, u16), +} +impl ThumbValue { + /// Thumb offset. + pub fn offset(&self) -> Factor { + self.offset + } + + /// Thumb position among others. + /// + /// In a single value this is `(0, 1)`, in a range this is `(0, 2)` for the start thumb and `(1, 2)` for the end thumb. + pub fn n_of(&self) -> (u16, u16) { + self.n_of + } + + /// Is first thumb (smallest offset). + pub fn is_first(&self) -> bool { + self.n_of.0 == 0 + } + + /// Is last thumb (largest offset). + pub fn is_last(&self) -> bool { + self.n_of.0 == self.n_of.1 + } +} + +context_local! { + /// Contextual [`Selector`]. + pub static SELECTOR: Selector = Selector::nil(); +} +context_var! { + /// Contextual thumb function. + pub static THUMB_FN_VAR: WidgetFn = wgt_fn!(|a: ThumbArgs| thumb::Thumb!(a.thumb())); +} + +/// Sets the slider selector that defines the values, ranges that are selected. +#[property(CONTEXT, default(Selector::nil()), widget_impl(Slider))] +pub fn selector(child: impl UiNode, selector: impl IntoValue) -> impl UiNode { + with_context_local(child, &SELECTOR, selector) +} + +/// Widget function that converts [`ThumbArgs`] to widgets. +#[property(CONTEXT, default(THUMB_FN_VAR))] +pub fn thumb_fn(child: impl UiNode, thumb: impl IntoVar>) -> impl UiNode { + with_context_var(child, THUMB_FN_VAR, thumb) +} + +/// Arguments for a slider thumb widget generator. +pub struct ThumbArgs { + thumb: ArcVar, +} +impl ThumbArgs { + /// Variable with the thumb value that must be represented by the widget. + pub fn thumb(&self) -> ReadOnlyArcVar { + self.thumb.read_only() + } +} + +/// Slider extension methods for widget info. +pub trait WidgetInfoExt { + /// Widget inner bounds define the slider range length. + fn is_slider_track(&self) -> bool; + + /// Find the nearest ancestor that is a slider track. + fn slider_track(&self) -> Option; +} +impl WidgetInfoExt for WidgetInfo { + fn is_slider_track(&self) -> bool { + self.meta().flagged(*IS_SLIDER_ID) + } + + fn slider_track(&self) -> Option { + self.self_and_ancestors().find(|w| w.is_slider_track()) + } +} + +static_id! { + static ref IS_SLIDER_ID: StateId<()>; +} + +/// Slider orientation and direction. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SliderDirection { + /// Horizontal. Minimum at start, maximum at end. + /// + /// Start is left in LTR contexts and right in RTL contexts. + StartToEnd, + /// Horizontal. Minimum at end, maximum at start. + /// + /// Start is left in LTR contexts and right in RTL contexts. + EndToStart, + /// Horizontal. Minimum at left, maximum at right. + LeftToRight, + /// Horizontal. Minimum at right, maximum at left. + RightToLeft, + /// Vertical. Minimum at bottom, maximum at top. + BottomToTop, + /// Vertical. Minimum at top, maximum at bottom. + TopToBottom, +} +impl SliderDirection { + /// Slider track is vertical. + pub fn is_vertical(&self) -> bool { + matches!(self, Self::BottomToTop | Self::TopToBottom) + } + + /// Slider track is horizontal. + pub fn is_horizontal(&self) -> bool { + !self.is_vertical() + } + + /// Convert start/end to left/right in the given `direction` context. + pub fn layout(&self, direction: LayoutDirection) -> Self { + match *self { + SliderDirection::StartToEnd => { + if direction.is_ltr() { + Self::LeftToRight + } else { + Self::RightToLeft + } + } + SliderDirection::EndToStart => { + if direction.is_ltr() { + Self::RightToLeft + } else { + Self::LeftToRight + } + } + s => s, + } + } +} +impl fmt::Debug for SliderDirection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if f.alternate() { + write!(f, "SliderDirection::")?; + } + match self { + Self::StartToEnd => write!(f, "StartToEnd"), + Self::EndToStart => write!(f, "EndToStart"), + Self::LeftToRight => write!(f, "LeftToRight"), + Self::RightToLeft => write!(f, "RightToLeft"), + Self::BottomToTop => write!(f, "BottomToTop"), + Self::TopToBottom => write!(f, "TopToBottom"), + } + } +} + +context_var! { + /// Orientation and direction of the parent slider. + pub static SLIDER_DIRECTION_VAR: SliderDirection = SliderDirection::StartToEnd; +} + +/// Defines the orientation and direction of the slider track. +/// +/// This property sets the [`SLIDER_DIRECTION_VAR`]. +#[property(CONTEXT, default(SLIDER_DIRECTION_VAR), widget_impl(Slider))] +fn direction(child: impl UiNode, direction: impl IntoVar) -> impl UiNode { + with_context_var(child, SLIDER_DIRECTION_VAR, direction) +} + +/// Slider track container widget. +/// +/// The slider track widget is an special container that generates thumb widgets for the slider. The widget +/// inner bounds define the track area/range. +#[widget($crate::SliderTrack)] +pub struct SliderTrack(WidgetBase); +impl SliderTrack { + fn widget_intrinsic(&mut self) { + self.widget_builder().push_build_action(|wgt| { + wgt.set_child(slider_track_node()); + }) + } +} + +fn slider_track_node() -> impl UiNode { + let mut thumbs = ui_vec![]; + let mut thumb_vars = vec![]; + match_node_leaf(move |op| match op { + UiNodeOp::Init => { + WIDGET.sub_var(&THUMB_FN_VAR); + + thumb_vars = SELECTOR.get().thumbs().into_iter().map(zng_var::var).collect(); + thumbs.reserve(thumb_vars.len()); + + let thumb_fn = THUMB_FN_VAR.get(); + for v in &thumb_vars { + thumbs.push(thumb_fn(ThumbArgs { thumb: v.clone() })) + } + + thumbs.init_all(); + } + UiNodeOp::Deinit => { + thumbs.deinit_all(); + thumbs = ui_vec![]; + thumb_vars = vec![]; + } + UiNodeOp::Info { info } => { + info.flag_meta(*IS_SLIDER_ID); + thumbs.info_all(info); + } + UiNodeOp::Measure { desired_size, .. } => { + *desired_size = LAYOUT.constraints().fill_size(); + } + UiNodeOp::Layout { final_size, wl } => { + *final_size = LAYOUT.constraints().fill_size(); + let _ = thumbs.layout_each(wl, |_, n, wl| n.layout(wl), |_, _| PxSize::zero()); + } + UiNodeOp::Update { updates } => { + if let Some(thumb_fn) = THUMB_FN_VAR.get_new() { + thumbs.deinit_all(); + thumb_vars.clear(); + thumbs.clear(); + + for value in SELECTOR.get().thumbs() { + let var = zng_var::var(value); + let thumb = thumb_fn(ThumbArgs { thumb: var.clone() }); + thumb_vars.push(var); + thumbs.push(thumb); + } + + thumbs.init_all(); + + WIDGET.update_info().layout().render(); + } else { + thumbs.update_all(updates, &mut ()); + + // sync views and vars with updated SELECTOR thumbs + + let mut thumb_values = SELECTOR.get().thumbs(); + match thumb_values.len().cmp(&thumb_vars.len()) { + std::cmp::Ordering::Less => { + // now has less thumbs + for mut drop in thumbs.drain(thumb_values.len()..) { + drop.deinit(); + } + thumb_vars.truncate(thumbs.len()); + } + std::cmp::Ordering::Greater => { + // now has more thumbs + let thumb_fn = THUMB_FN_VAR.get(); + for value in thumb_values.drain(thumbs.len()..) { + let var = zng_var::var(value); + let mut thumb = thumb_fn(ThumbArgs { thumb: var.clone() }); + thumb.init(); + thumb_vars.push(var); + thumbs.push(thumb); + } + } + std::cmp::Ordering::Equal => {} + } + + // reuse thumbs + for (var, value) in thumb_vars.iter().zip(thumb_values) { + var.set(value); + } + } + } + op => thumbs.op(op), + }) +} + +macro_rules! impl_32 { + (($to_f32:expr, $from_f32:expr) => $($T:ident),+ $(,)?) => { + $( + impl SelectorValue for $T { + #[allow(clippy::unnecessary_cast)] + fn to_selector(value: BoxedVar, min: Self, max: Self) -> Selector { + let to_f32 = $to_f32; + let from_f32 = $from_f32; + + let min = to_f32(min); + let max = to_f32(max); + if min >= max { + Selector::nil() + } else { + let d = max - min; + Selector::value_with(value, move |i| { + let i = to_f32(i.clone()); + ((i - min) / d).fct() + }, move |f| { + from_f32((f.0 * d + min).round()) + }) + } + } + } + )+ + }; +} +impl_32!((|i| i as f32, |f| f as Self) => u32, i32, u16, i16, u8, i8, f32); +impl_32!((|p: Self| p.0 as f32, |f| Self(f as _)) => Px, Factor, FactorPercent); +impl_32!((|p: Dip| p.to_f32(), |f| Dip::new_f32(f)) => Dip); + +macro_rules! impl_64 { + ($($T:ident),+ $(,)?) => { + $( + impl SelectorValue for $T { + fn to_selector(value: BoxedVar, min: Self, max: Self) -> Selector { + let min = min as f64; + let max = max as f64; + if min >= max { + Selector::nil() + } else { + let d = max - min; + Selector::value_with(value, move |&i| { + let i = i as f64; + Factor(((i - min) / d) as f32) + }, move |f| { + ((f.0 as f64) * d + min).round() as Self + }) + } + } + } + )+ + }; +} +impl_64!(u64, i64, u128, i128, f64); + +impl SelectorValue for Length { + fn to_selector(value: BoxedVar, min: Self, max: Self) -> Selector { + let (min_f32, max_f32) = LAYOUT.with_context(LayoutMetrics::new(1.fct(), PxSize::splat(Px(1000)), Px(16)), || { + (min.layout_f32(LayoutAxis::X), max.layout_f32(LayoutAxis::X)) + }); + if min_f32 >= max_f32 { + Selector::nil() + } else { + let d_f32 = max_f32 - min_f32; + let d = max - min.clone(); + Selector::value_with( + value, + move |l| { + let l = LAYOUT.with_context(LayoutMetrics::new(1.fct(), PxSize::splat(Px(1000)), Px(16)), || { + l.layout_f32(LayoutAxis::X) + }); + Factor((l - min_f32) / d_f32) + }, + move |f| d.clone() * f + min.clone(), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn selector_value_t(min: T, max: T) { + let s = Selector::value(min.clone(), min.clone(), max.clone()); + assert_eq!(s.to_offset(&min), Some(0.fct())); + assert_eq!(s.to_offset(&max), Some(1.fct())); + assert_eq!(s.from_offset(0.fct()), Some(min)); + assert_eq!(s.from_offset(1.fct()), Some(max)); + } + + #[test] + fn selector_value_u8() { + selector_value_t(u8::MIN, u8::MAX); + selector_value_t(20u8, 120u8); + } + + #[test] + fn selector_value_i32() { + selector_value_t(i32::MIN, i32::MAX); + selector_value_t(20i32, 120i32); + } + + #[test] + fn selector_value_i64() { + selector_value_t(i64::MIN, i64::MAX); + } + + #[test] + fn selector_value_f64() { + selector_value_t(-200f64, 200f64); + selector_value_t(20f64, 120f64); + } + + #[test] + fn selector_pct() { + selector_value_t(0.pct(), 100.pct()); + } + + #[test] + fn selector_value_px() { + selector_value_t(Px(20), Px(200)); + } + + #[test] + fn selector_value_dip() { + selector_value_t(Dip::new(20), Dip::new(200)); + } + + #[test] + fn selector_value_length() { + selector_value_t(20.px(), 200.px()); + } + + #[test] + fn selector_value_set() { + let s = Selector::value(10u8, 0, 100); + s.set(10.pct(), 20.pct()); + assert_eq!(s.thumbs()[0].offset, 0.2.fct()); + } + + #[test] + fn selector_range_set() { + let s = Selector::range(10u8..20u8, 0, 100); + // less then first + s.set(0.pct(), 5.pct()); + assert_eq!(s.thumbs()[0].offset, 0.05.fct()); + assert_eq!(s.thumbs()[1].offset, 0.2.fct()); + + // more then last + s.set(25.pct(), 30.pct()); + assert_eq!(s.thumbs()[0].offset, 0.05.fct()); + assert_eq!(s.thumbs()[1].offset, 0.3.fct()); + + // nearest first + s.set(6.pct(), 7.pct()); + assert_eq!(s.thumbs()[0].offset, 0.07.fct()); + assert_eq!(s.thumbs()[1].offset, 0.3.fct()); + + // invert + s.set(7.pct(), 40.pct()); + assert_eq!(s.thumbs()[0].offset, 0.3.fct()); + assert_eq!(s.thumbs()[1].offset, 0.4.fct()); + } + + #[test] + fn selector_range_set_eq() { + let s = Selector::range(10u8..10, 0, 100); + assert_eq!(s.thumbs()[0].offset, 0.1.fct()); + assert_eq!(s.thumbs()[1].offset, 0.1.fct()); + + // only the last must move + s.set(10.pct(), 20.pct()); + assert_eq!(s.thumbs()[0].offset, 0.1.fct()); + assert_eq!(s.thumbs()[1].offset, 0.2.fct()); + + let s = Selector::range(10u8..10, 0, 100); + s.set(5.pct(), 5.pct()); + assert_eq!(s.thumbs()[0].offset, 0.05.fct()); + assert_eq!(s.thumbs()[1].offset, 0.1.fct()); + } +} diff --git a/crates/zng-wgt-slider/src/thumb.rs b/crates/zng-wgt-slider/src/thumb.rs new file mode 100644 index 000000000..841cb10da --- /dev/null +++ b/crates/zng-wgt-slider/src/thumb.rs @@ -0,0 +1,146 @@ +//! Slider thumb widget. + +use zng_ext_input::mouse::MOUSE_MOVE_EVENT; +use zng_wgt::prelude::*; +use zng_wgt_input::{focus::FocusableMix, pointer_capture::capture_pointer}; +use zng_wgt_style::{impl_style_fn, style_fn, Style, StyleMix}; + +use crate::{SliderDirection, ThumbValue, WidgetInfoExt as _, SLIDER_DIRECTION_VAR}; + +/// Slider thumb widget. +#[widget($crate::thumb::Thumb { + ($value:expr) => { + value = $value; + } +})] +pub struct Thumb(FocusableMix>); +impl Thumb { + fn widget_intrinsic(&mut self) { + self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn)); + + self.widget_builder() + .push_build_action(|wgt| match wgt.capture_var::(property_id!(Self::value)) { + Some(v) => { + wgt.push_intrinsic(NestGroup::LAYOUT, "event-layout", move |c| thumb_event_layout_node(c, v)); + } + None => tracing::error!("missing required `slider::Thumb::value` property"), + }); + + widget_set! { + self; + style_base_fn = style_fn!(|_| DefaultStyle!()); + capture_pointer = true; + } + } +} +impl_style_fn!(Thumb); + +/// Default slider style. +#[widget($crate::thumb::DefaultStyle)] +pub struct DefaultStyle(Style); +impl DefaultStyle { + fn widget_intrinsic(&mut self) { + widget_set! { + self; + zng_wgt::border = 3, LightDark::new(colors::BLACK, colors::WHITE).rgba_into(); + zng_wgt_size_offset::force_size = 10 + 3 + 3; + zng_wgt::corner_radius = 16; + zng_wgt_fill::background_color = colors::ACCENT_COLOR_VAR.rgba(); + + when #{crate::SLIDER_DIRECTION_VAR}.is_horizontal() { + zng_wgt_size_offset::offset = (-3 -10/2, -3 -5/2); // track is 5 height + } + when #{crate::SLIDER_DIRECTION_VAR}.is_vertical() { + zng_wgt_size_offset::offset = (-3 -5/2, -3 -10/2); + } + + #[easing(150.ms())] + zng_wgt_transform::scale = 100.pct(); + when *#zng_wgt_input::is_cap_hovered { + #[easing(0.ms())] + zng_wgt_transform::scale = 120.pct(); + } + } + } +} + +/// Value represented by the thumb. +#[property(CONTEXT, capture, widget_impl(Thumb))] +pub fn value(thumb: impl IntoVar) {} + +/// Main thumb implementation. +/// +/// Handles mouse and touch drag, applies the thumb offset as translation on layout. +fn thumb_event_layout_node(child: impl UiNode, value: impl IntoVar) -> impl UiNode { + let value = value.into_var(); + let mut layout_direction = LayoutDirection::LTR; + match_node(child, move |c, op| match op { + UiNodeOp::Init => { + WIDGET.sub_var_layout(&value).sub_event(&MOUSE_MOVE_EVENT); + } + UiNodeOp::Event { update } => { + c.event(update); + if let Some(args) = MOUSE_MOVE_EVENT.on_unhandled(update) { + if let Some(c) = &args.capture { + if c.target.widget_id() == WIDGET.id() { + let thumb_info = WIDGET.info(); + let track_info = match thumb_info.slider_track() { + Some(i) => i, + None => { + tracing::error!("slider::Thumb is not inside a slider_track"); + return; + } + }; + args.propagation().stop(); + + let track_bounds = track_info.inner_bounds(); + let track_orientation = SLIDER_DIRECTION_VAR.get(); + + let (track_min, track_max) = match track_orientation.layout(layout_direction) { + SliderDirection::LeftToRight => (track_bounds.min_x(), track_bounds.max_x()), + SliderDirection::RightToLeft => (track_bounds.max_x(), track_bounds.min_x()), + SliderDirection::BottomToTop => (track_bounds.max_y(), track_bounds.min_y()), + SliderDirection::TopToBottom => (track_bounds.min_y(), track_bounds.max_y()), + _ => unreachable!(), + }; + let cursor = if track_orientation.is_horizontal() { + args.position.x.to_px(track_info.tree().scale_factor()) + } else { + args.position.y.to_px(track_info.tree().scale_factor()) + }; + let new_offset = (cursor - track_min).0 as f32 / (track_max - track_min).abs().0 as f32; + + let selector = crate::SELECTOR.get(); + selector.set(value.get().offset(), new_offset.fct().clamp_range()); + WIDGET.update(); + } + } + } + } + UiNodeOp::Layout { wl, final_size } => { + *final_size = c.layout(wl); + layout_direction = LAYOUT.direction(); + + // max if bounded, otherwise min. + let c = LAYOUT.constraints(); + let track_size = c.with_fill_vector(c.is_bounded()).fill_size(); + let track_orientation = SLIDER_DIRECTION_VAR.get(); + let offset = value.get().offset; + + let offset = match track_orientation.layout(layout_direction) { + SliderDirection::LeftToRight => track_size.width * offset, + SliderDirection::RightToLeft => track_size.width - (track_size.width * offset), + SliderDirection::BottomToTop => track_size.height - (track_size.height * offset), + SliderDirection::TopToBottom => track_size.height * offset, + _ => unreachable!(), + }; + let offset = if track_orientation.is_horizontal() { + PxVector::new(offset, Px(0)) + } else { + PxVector::new(Px(0), offset) + }; + wl.translate(offset); + } + _ => {} + }) +} diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml index d42dc9141..14b118ff9 100644 --- a/crates/zng/Cargo.toml +++ b/crates/zng/Cargo.toml @@ -258,6 +258,7 @@ zng-wgt-inspector = { path = "../zng-wgt-inspector", version = "0.2.43" } zng-wgt-settings = { path = "../zng-wgt-settings", version = "0.1.23" } zng-wgt-dialog = { path = "../zng-wgt-dialog", version = "0.1.18" } zng-wgt-progress = { path = "../zng-wgt-progress", version = "0.1.2" } +zng-wgt-slider = { path = "../zng-wgt-slider", version = "0.1.0" } zng-wgt-material-icons = { path = "../zng-wgt-material-icons", version = "0.3.22", default-features = false, optional = true } zng-ext-single-instance = { path = "../zng-ext-single-instance", version = "0.3.23", optional = true } diff --git a/crates/zng/src/lib.rs b/crates/zng/src/lib.rs index 53f88e7f6..40ee0275f 100644 --- a/crates/zng/src/lib.rs +++ b/crates/zng/src/lib.rs @@ -519,6 +519,7 @@ pub mod render; pub mod rule_line; pub mod scroll; pub mod selectable; +pub mod slider; pub mod stack; pub mod state_map; pub mod style; diff --git a/crates/zng/src/slider.rs b/crates/zng/src/slider.rs new file mode 100644 index 000000000..428ed691a --- /dev/null +++ b/crates/zng/src/slider.rs @@ -0,0 +1,37 @@ +//! Slider widget, styles and properties. +//! +//! This widget allows selecting a value or range by dragging a selector thumb over a range line. +//! +//! ``` +//! # use zng::prelude::*; +//! # let _scope = APP.defaults(); +//! let value = var(0u8); +//! # let _ = +//! zng::slider::Slider! { +//! // declare slider with single thumb +//! selector = zng::slider::Selector::value(value.clone(), 0, 100); +//! // show selected value +//! zng::container::child_out_bottom = Text!(value.map_debug()), 5; +//! } +//! # ; +//! ``` +//! +//! The example above creates a a slider with a single thumb that selects a `u8` value in the `0..=100` range. The [`Selector`] +//! type also supports creating multiple thumbs and custom range conversions. +//! +//! # Full API +//! +//! See [`zng_wgt_slider`] for the full widget API. + +pub use zng_wgt_slider::{DefaultStyle, Selector, SelectorValue, Slider, SliderDirection, SliderTrack, ThumbArgs, SLIDER_DIRECTION_VAR}; + +/// Slider thumb widget, styles and properties. +/// +/// This widget represents one value/offset in a slider. +/// +/// # Full API +/// +/// See [`zng_wgt_slider::thumb`] for the full widget API. +pub mod thumb { + pub use zng_wgt_slider::thumb::{DefaultStyle, Thumb}; +}