From cd89ad8acd83163dc954d4c2c092e8b7f87e1ffc Mon Sep 17 00:00:00 2001 From: yvt Date: Sun, 17 May 2020 15:30:04 +0900 Subject: [PATCH] feat(ui): support displaying custom label views in `Slider` --- tcw3/examples/tcw3_widgets.rs | 32 ++++- tcw3/src/ui/theming/style.rs | 7 +- tcw3/src/ui/theming/stylesheet.rs | 66 +++++++-- tcw3/src/ui/views/slider.rs | 224 +++++++++++++++++++++++++----- 4 files changed, 272 insertions(+), 57 deletions(-) diff --git a/tcw3/examples/tcw3_widgets.rs b/tcw3/examples/tcw3_widgets.rs index d1621b09..27a9f647 100644 --- a/tcw3/examples/tcw3_widgets.rs +++ b/tcw3/examples/tcw3_widgets.rs @@ -3,7 +3,7 @@ use tcw3::{ pal, pal::prelude::*, ui::{ - layouts::TableLayout, + layouts::{FillLayout, TableLayout}, theming, views::{ scrollbar::ScrollbarDragListener, Button, Checkbox, Entry, Label, RadioButton, @@ -123,9 +123,29 @@ fn main() { }); } + let slider_labels = [ + Label::new(style_manager), + Label::new(style_manager), + Label::new(style_manager), + Label::new(style_manager), + Label::new(style_manager), + ]; + slider_labels[0].set_text("Stop"); + slider_labels[1].set_text("Trot"); + slider_labels[2].set_text("Canter"); + slider_labels[3].set_text("Gallop"); + slider_labels[4].set_text("Warp"); + let slider = Slider::new(style_manager, false); let slider = Rc::new(slider); - slider.set_uniform_ticks(8); + slider.set_uniform_ticks(5); + slider.set_labels([ + (0, Some((0.0, &slider_labels[0] as &dyn theming::Widget))), + (1, Some((0.2, &slider_labels[1] as &dyn theming::Widget))), + (2, Some((0.4, &slider_labels[2] as &dyn theming::Widget))), + (3, Some((0.6, &slider_labels[3] as &dyn theming::Widget))), + (4, Some((1.0, &slider_labels[4] as &dyn theming::Widget))), + ]); { let slider_weak = Rc::downgrade(&slider); slider.set_on_drag(move |_| { @@ -138,6 +158,12 @@ fn main() { // TODO } + let slider = { + let view = HView::new(Default::default()); + view.set_layout(FillLayout::new(slider.view()).with_margin([0.0, 10.0, 0.0, 10.0])); + view + }; + let entry = Entry::new(style_manager); let button = Button::new(style_manager); @@ -214,7 +240,7 @@ fn main() { TableLayout::stack_vert(vec![ (label.view(), AlignFlags::VERT_JUSTIFY), (scrollbar.view(), AlignFlags::JUSTIFY), - (slider.view(), AlignFlags::JUSTIFY), + (slider, AlignFlags::JUSTIFY), (entry.view(), AlignFlags::JUSTIFY), (h_layout.clone(), AlignFlags::JUSTIFY), ]) diff --git a/tcw3/src/ui/theming/style.rs b/tcw3/src/ui/theming/style.rs index 8a00c722..da4cd058 100644 --- a/tcw3/src/ui/theming/style.rs +++ b/tcw3/src/ui/theming/style.rs @@ -111,6 +111,7 @@ pub mod elem_id { , TEXT_SELECTION , SLIDER_KNOB , SLIDER_TICKS + , SLIDER_LABELS } } @@ -135,10 +136,10 @@ pub mod roles { pub const GENERIC: super::Role = iota; , HORZ_SCROLLBAR , VERT_SCROLLBAR + , SLIDER_KNOB + , SLIDER_TICKS + , SLIDER_LABELS } - - pub const SLIDER_KNOB: super::Role = super::Role::max_value(); - pub const SLIDER_TICKS: super::Role = super::Role::max_value() - 1; } #[macro_use] diff --git a/tcw3/src/ui/theming/stylesheet.rs b/tcw3/src/ui/theming/stylesheet.rs index d6d7f55f..94c92aa2 100644 --- a/tcw3/src/ui/theming/stylesheet.rs +++ b/tcw3/src/ui/theming/stylesheet.rs @@ -548,14 +548,18 @@ const SCROLLBAR_VISUAL_RADIUS: f32 = SCROLLBAR_VISUAL_WIDTH / 2.0; const SCROLLBAR_MARGIN: f32 = 6.0; const SCROLLBAR_LEN_MIN: f32 = 20.0; +/// The width of the slider. Does not include custom label views. const SLIDER_WIDTH: f32 = 28.0; const SLIDER_TROUGH_WIDTH: f32 = 1.0; const SLIDER_KNOB_SIZE: f32 = 16.0; const SLIDER_KNOB_RADIUS: f32 = SLIDER_KNOB_SIZE / 2.0; const SLIDER_LEN_MARGIN: f32 = 10.0; const SLIDER_LEN_MIN: f32 = SLIDER_LEN_MARGIN * 2.0 + 10.0; -const SLIDER_TICKS_DISTANCE: f32 = 4.0; +const SLIDER_TICKS_DISTANCE: f32 = SLIDER_KNOB_RADIUS + 4.0; const SLIDER_TICKS_SIZE: f32 = 3.0; +const SLIDER_LABELS_DISTANCE: f32 = SLIDER_TICKS_DISTANCE + SLIDER_TICKS_SIZE + 3.0; +/// The margin between custom label views and the slider's frame. +const SLIDER_LABELS_MARGIN: f32 = 2.0; const FIELD_HEIGHT: f32 = 20.0; @@ -860,30 +864,56 @@ lazy_static! { layer_opacity[0]: 1.0, }, ([.SLIDER:not(.VERTICAL)]) (priority = 100) { + // This subview metrics only determines its movable region. The + // final frame is decided by `*StyledBoxOverride` based on that. subview_metrics[roles::SLIDER_KNOB]: Metrics { - margin: [NAN, SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, NAN, SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS], + margin: [ + SLIDER_WIDTH * 0.5 - SLIDER_KNOB_RADIUS, + SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, + NAN, + SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, + ], .. Metrics::default() }, subview_metrics[roles::SLIDER_TICKS]: Metrics { margin: [ - SLIDER_WIDTH * 0.5 + SLIDER_KNOB_RADIUS +SLIDER_TICKS_DISTANCE, + SLIDER_WIDTH * 0.5 + SLIDER_TICKS_DISTANCE, SLIDER_LEN_MARGIN, NAN, SLIDER_LEN_MARGIN, ], size: Vector2::new(NAN, SLIDER_TICKS_SIZE), }, + subview_metrics[roles::SLIDER_LABELS]: Metrics { + margin: [ + SLIDER_WIDTH * 0.5 + SLIDER_LABELS_DISTANCE, + SLIDER_LEN_MARGIN, + SLIDER_LABELS_MARGIN, + SLIDER_LEN_MARGIN, + ], + size: Vector2::new(NAN, NAN), + }, allow_grow: [true, false], min_size: Vector2::new(SLIDER_LEN_MIN, SLIDER_WIDTH), layer_metrics[0]: Metrics { - margin: [NAN, SLIDER_LEN_MARGIN, NAN, SLIDER_LEN_MARGIN], + margin: [ + SLIDER_WIDTH * 0.5 - SLIDER_TROUGH_WIDTH * 0.5, + SLIDER_LEN_MARGIN, + NAN, + SLIDER_LEN_MARGIN, + ], size: Vector2::new(NAN, SLIDER_TROUGH_WIDTH), }, }, ([.SLIDER.VERTICAL]) (priority = 100) { subview_metrics[roles::SLIDER_KNOB]: Metrics { - margin: [SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, NAN, SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, NAN], + margin: [ + SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, + NAN, + SLIDER_LEN_MARGIN - SLIDER_KNOB_RADIUS, + SLIDER_WIDTH * 0.5 - SLIDER_KNOB_RADIUS, + ], .. Metrics::default() }, subview_metrics[roles::SLIDER_TICKS]: Metrics { @@ -891,31 +921,41 @@ lazy_static! { SLIDER_LEN_MARGIN, NAN, SLIDER_LEN_MARGIN, - SLIDER_WIDTH * 0.5 + SLIDER_KNOB_RADIUS +SLIDER_TICKS_DISTANCE, + SLIDER_WIDTH * 0.5 + SLIDER_TICKS_DISTANCE, ], size: Vector2::new(NAN, SLIDER_TICKS_SIZE), }, + subview_metrics[roles::SLIDER_LABELS]: Metrics { + margin: [ + SLIDER_LEN_MARGIN, + SLIDER_LABELS_MARGIN, + SLIDER_LEN_MARGIN, + SLIDER_WIDTH * 0.5 + SLIDER_LABELS_DISTANCE, + ], + size: Vector2::new(NAN, NAN), + }, allow_grow: [false, true], min_size: Vector2::new(SLIDER_WIDTH, SLIDER_LEN_MIN), layer_metrics[0]: Metrics { - margin: [SLIDER_LEN_MARGIN, NAN, SLIDER_LEN_MARGIN, NAN], + margin: [ + SLIDER_LEN_MARGIN, + NAN, + SLIDER_LEN_MARGIN, + SLIDER_WIDTH * 0.5 - SLIDER_TROUGH_WIDTH * 0.5, + ], size: Vector2::new(SLIDER_TROUGH_WIDTH, NAN), }, }, // Slider knob - ([] < [.SLIDER]) (priority = 100) { + ([#SLIDER_KNOB] < [.SLIDER]) (priority = 100) { num_layers: 1, #[dyn] layer_img[0]: Some(recolor_tint(&assets::SLIDER_KNOB)), min_size: Vector2::new(SLIDER_KNOB_SIZE, SLIDER_KNOB_SIZE), }, - ([] < [.SLIDER:not(.VERTICAL)]) (priority = 100) { - }, - ([] < [.SLIDER.VERTICAL]) (priority = 100) { - }, - ([] < [.SLIDER.ACTIVE]) (priority = 150) { + ([#SLIDER_KNOB] < [.SLIDER.ACTIVE]) (priority = 150) { #[dyn] layer_img[0]: Some(recolor_tint(&assets::SLIDER_KNOB_ACT)), }, diff --git a/tcw3/src/ui/views/slider.rs b/tcw3/src/ui/views/slider.rs index 23124b99..9056e304 100644 --- a/tcw3/src/ui/views/slider.rs +++ b/tcw3/src/ui/views/slider.rs @@ -12,12 +12,12 @@ use crate::{ pal, prelude::*, ui::{ - layouts::FillLayout, + layouts::{EmptyLayout, FillLayout}, mixins::CanvasMixin, theming, theming::{ elem_id, roles, ClassSet, GetPropValue, HElem, Manager, ModifyArrangementArgs, - PropKindFlags, StyledBox, StyledBoxOverride, Widget, + PropKindFlags, Role, StyledBox, StyledBoxOverride, Widget, }, }, uicore::{HView, HViewRef, HWndRef, MouseDragListener, UpdateCtx, ViewFlags, ViewListener}, @@ -31,13 +31,19 @@ pub use super::scrollbar::{Dir, ScrollbarDragListener}; /// A slider widget. /// +/// # Custom Label Views +/// +/// A slider can have *custom label views* tethered to specific values. They can +/// be set by calling [`Slider::set_labels`]. +/// +/// *Performance notes:* Custom label views use O(n²) algorithms in many of +/// their code paths. Please keep the number of views low (< 8). +/// /// # Styling /// /// - `style_elem` - See [`StyledBox`](crate::ui::theming::StyledBox) -/// - `subviews[role]`: A custom label view with a role `role`. -/// The primary axis range of `frame` is overriden using the label's -/// value. The original `frame` represents the value range. The size along -/// the primary axis is always set to minimum. +/// - [`subviews[roles::SLIDER_LABELS]`]: The wrapper for custom label +/// views. /// /// - [`subviews[roles::SLIDER_KNOB]`]: The knob. `Slider` overrides the /// knob's `frame` using the current value. The original `frame` @@ -54,17 +60,24 @@ pub use super::scrollbar::{Dir, ScrollbarDragListener}; /// Should align with the movable range of the knob for it to make sense /// to the application user. /// -/// - `style_elem > *` - Custom label views. -/// /// - `style_elem > `[`#SLIDER_KNOB`] - The knob. See /// [`StyledBox`](crate::ui::theming::StyledBox) /// /// - `style_elem > `[`#SLIDER_TICKS`] - The ticks. Supports `FgColor`. /// +/// - `style_elem > `[`#SLIDER_LABELS`] - The wrapper for custom label views. +/// - `subviews[role]`: The custom label view with a role `role`. +/// The original `frame` represents the value range. The size along +/// the primary axis is always set to minimum. +/// +/// - `style_elem > #SLIDER_LABELS > *` - Custom label views. +/// /// [`subviews[roles::SLIDER_KNOB]`]: crate::ui::theming::roles::SLIDER_KNOB /// [`subviews[roles::SLIDER_TICKS]`]: crate::ui::theming::roles::SLIDER_TICKS +/// [`subviews[roles::SLIDER_LABELS]`]: crate::ui::theming::roles::SLIDER_LABELS /// [`#SLIDER_KNOB`]: crate::ui::theming::elem_id::SLIDER_KNOB /// [`#SLIDER_TICKS`]: crate::ui::theming::elem_id::SLIDER_TICKS +/// [`#SLIDER_LABELS`]: crate::ui::theming::elem_id::SLIDER_LABELS /// #[derive(Debug)] pub struct Slider { @@ -85,6 +98,17 @@ struct Shared { ticks_elem: theming::Elem, ticks_state: Rc>, ticks_empty: Cell, + + labels_wrapper: StyledBox, + labels: RefCell>, +} + +struct Label { + role: Role, + value: f32, + /// Wraps the given view to make sure `SizeTraits::max` is infinity. + /// (We don't want the slider's maximum size limited by the labels within) + wrap_view: HView, } type DragHandler = Box Box>; @@ -143,6 +167,10 @@ impl Slider { knob.set_class_set(elem_id::SLIDER_KNOB); frame.set_child(roles::SLIDER_KNOB, Some(&knob)); + // Wrapper for custom labels + let labels_wrapper = StyledBox::new(style_manager, ViewFlags::default()); + labels_wrapper.set_class_set(elem_id::SLIDER_LABELS); + let shared = Rc::new(Shared { vertical, value: Cell::new(0.0), @@ -156,6 +184,8 @@ impl Slider { ticks_elem, ticks_state, ticks_empty: Cell::new(true), + labels_wrapper, + labels: RefCell::new(Vec::new()), }); Shared::update_sb_override(&shared); @@ -256,6 +286,81 @@ impl Slider { ); } + /// Set custom label views attached to specified values. + pub fn set_labels<'a>(&self, children: impl AsRef<[(Role, Option<(f64, &'a dyn Widget)>)]>) { + self.set_labels_inner(children.as_ref()); + } + + fn set_labels_inner(&self, children: &[(Role, Option<(f64, &dyn Widget)>)]) { + let mut labels = self.shared.labels.borrow_mut(); + let labels_wrapper = &self.shared.labels_wrapper; + + // `wrap_view` has flexible margin values so that the slider's maximum + // size is not constrained by individual custom labels. + use std::f32::NAN; + let wrap_view_margin = [ + // Horizontal - the top edge of the label is fixed + [0.0, NAN, NAN, NAN], + // Vertical - the left edge of the label is fixed + [NAN, NAN, NAN, 0.0], + ][self.shared.vertical as usize]; + + for &(role, label) in children.iter() { + if let Some((new_value, new_widget)) = label { + let new_value = new_value as f32; + let wrap_view = + if let Some(label) = labels.iter_mut().find(|label| label.role == role) { + // Replace the existing label + label.value = new_value; + &label.wrap_view + } else { + // Add a new label + let wrap_view = HView::new(ViewFlags::default()); + labels_wrapper.set_subview(role, Some(wrap_view.clone())); + labels.push(Label { + role, + wrap_view, + value: new_value, + }); + &labels.last_mut().unwrap().wrap_view + }; + wrap_view.set_layout( + FillLayout::new(new_widget.view_ref().cloned()).with_margin(wrap_view_margin), + ); + } else { + if let Some(i) = labels.iter().position(|label| label.role == role) { + // Remove the label + let label = labels.swap_remove(i); + debug_assert_eq!(label.role, role); + labels_wrapper.set_subview(role, None); + label + .wrap_view + .set_layout(EmptyLayout::new(Default::default())); + } + } + + labels_wrapper.set_subelement(role, label.and_then(|(_, widget)| widget.style_elem())); + } + + if labels.is_empty() { + self.shared.frame.set_child(roles::SLIDER_LABELS, None); + } else { + // Display `SLIDER_LABELS` only if there's a label. This way, the + // slider will have an extra size only when necessary. + self.shared + .frame + .set_child(roles::SLIDER_LABELS, Some(labels_wrapper)); + + labels_wrapper.set_override(LabelsStyledBoxOverride { + vertical: self.shared.vertical, + labels: labels + .iter() + .map(|label| (label.role, label.value)) + .collect(), + }); + } + } + /// Set the factory function for gesture event handlers used when the user /// grabs the knob. /// @@ -338,44 +443,42 @@ impl StyledBoxOverride for SlStyledBoxOverride { .. }: ModifyArrangementArgs<'_>, ) { - let shared = if let Some(shared) = self.shared.upgrade() { - shared - } else { - return; - }; - match role { - roles::SLIDER_KNOB => {} - roles::SLIDER_TICKS => { - return; + roles::SLIDER_TICKS | roles::SLIDER_LABELS => { + // Do not modify the arrangement of these subviews. } - _ => { - todo!(); - } - } + roles::SLIDER_KNOB => { + let shared = if let Some(shared) = self.shared.upgrade() { + shared + } else { + return; + }; - let pri = shared.vertical as usize; + let pri = shared.vertical as usize; - let bar_len = frame.size()[pri] as f64; - let bar_start = frame.min[pri] as f64; + let bar_len = frame.size()[pri] as f64; + let bar_start = frame.min[pri] as f64; - let knob_len = size_traits.min[pri] as f64; - let clearance = bar_len - knob_len; + let knob_len = size_traits.min[pri] as f64; + let clearance = bar_len - knob_len; - let knob_origin_start = bar_start + knob_len * 0.5; + let knob_origin_start = bar_start + knob_len * 0.5; - let knob_start = bar_start + self.value * clearance; - let knob_end = knob_start + knob_len; - frame.min[pri] = knob_start as f32; - frame.max[pri] = knob_end as f32; + let knob_start = bar_start + self.value * clearance; + let knob_end = knob_start + knob_len; + frame.min[pri] = knob_start as f32; + frame.max[pri] = knob_end as f32; - // Layout feedback - shared.layout_state.set(LayoutState { - knob_start: knob_start as f32, - knob_end: knob_end as f32, - clearance, - knob_origin_start, - }); + // Layout feedback + shared.layout_state.set(LayoutState { + knob_start: knob_start as f32, + knob_end: knob_end as f32, + clearance, + knob_origin_start, + }); + } + _ => unreachable!(), + } } fn dirty_flags(&self, other: &dyn StyledBoxOverride) -> PropKindFlags { @@ -392,6 +495,51 @@ impl StyledBoxOverride for SlStyledBoxOverride { } } +/// Implements `StyledBoxOverride` for custom label views. +struct LabelsStyledBoxOverride { + vertical: bool, + labels: Vec<(Role, f32)>, +} + +impl StyledBoxOverride for LabelsStyledBoxOverride { + fn modify_arrangement( + &self, + ModifyArrangementArgs { + size_traits, + frame, + role, + .. + }: ModifyArrangementArgs<'_>, + ) { + let pri = self.vertical as usize; + + let value = self + .labels + .iter() + .find(|&&(r, _)| r == role) + .map(|&(_, value)| value) + .unwrap_or(0.0); + + let label_size = size_traits.min[pri]; + let label_pos = frame.min[pri] + (frame.max[pri] - frame.min[pri]) * value; + frame.min[pri] = label_pos - label_size / 2.0; + frame.max[pri] = label_pos + label_size / 2.0; + } + + fn dirty_flags(&self, other: &dyn StyledBoxOverride) -> PropKindFlags { + use as_any::Downcast; + if let Some(other) = (*other).downcast_ref::() { + if self.labels == other.labels { + PropKindFlags::empty() + } else { + PropKindFlags::LAYOUT + } + } else { + PropKindFlags::all() + } + } +} + /// Implements `ViewListener` for `Slider`. struct SlViewListener { shared: Weak,